SSD Nodes Learn 🎉 VPS $5.50/মাস থেকে
নির্দেশিকা Matt Connorদ্বারা Matt Connor

নিজের VPS-এ RAG pipeline তৈরির সম্পূর্ণ গাইড

আপনার নিজস্ব VPS-এ কীভাবে RAG pipeline তৈরি করবেন তা শিখুন। এই গাইডে pgvector schema, HNSW ইনডেক্সিং, লোকাল embedding মডেল এবং ডেটা রিট্রিভাল যাচাইয়ের SQL কোড দেওয়া হয়েছে।

একটি self-hosted RAG pipeline দেখতে যেমন হয়

একটি RAG (retrieval augmented generation) pipeline-এর পাঁচটি ধাপ রয়েছে: ডকুমেন্টগুলোকে ছোট অংশে (chunk) ভাগ করা, সেই অংশগুলোকে embed করা, ভেক্টরগুলো সংরক্ষণ করা, প্রশ্নের সাথে মিল আছে এমন অংশগুলো খুঁজে বের করা এবং সবশেষে সেই অংশগুলোকে একটি ল্যাঙ্গুয়েজ মডেলের কাছে পাঠানো যা উত্তরটি তৈরি করবে। আপনি বর্তমানে যে VPS ভাড়া নিয়েছেন, তার মধ্যেই প্রথম চারটি ধাপ চালানো সম্ভব। PostgreSQL-এর pgvector extension ভেক্টরগুলো জমা রাখে এবং Ollama দ্বারা পরিচালিত একটি ছোট embedding মডেল টেক্সটকে ভেক্টরে রূপান্তর করে। শুধুমাত্র শেষ ধাপটির জন্য সার্ভারের বাইরে যেতে হয়।

এই বিভাজনই এই গাইডের মূল ভিত্তি। Chunking হলো সাধারণ CPU-এর কাজ। Embedding হলো 137 মিলিয়ন প্যারামিটারের একটি মডেল যা কয়েকশ মেগাবাইট RAM-এ চলে। স্টোরেজ হলো একটি Postgres টেবিল, যার আকার আপনি প্রথম সারি লেখার আগেই গাণিতিকভাবে হিসাব করতে পারবেন। কয়েক লক্ষ chunk-এর একটি কর্পাসের জন্য এই সবকিছুই একটি সাধারণ VPS-এ চালানো সম্ভব। Generation বা উত্তর তৈরির বিষয়টি আলাদা, কারণ প্রতিটি প্রশ্নের জন্য এটি সবসময় কিছু না কিছু খরচ তৈরি করে।

RAG পাইপলাইনের কোন অংশগুলোতে খরচ হয়

DigitalOcean-এর end to end RAG tutorial-এ একটি managed vector database এবং একটি hosted embedding model ব্যবহারের পরামর্শ দেওয়া হয়েছে, এবং এর খরচের অংশটি গুণগত: বারবার করা কুয়েরিগুলো ক্যাশ করুন, রিট্রিভ করা চাংকের সংখ্যা কম রাখুন, এবং জেনারেশনের আগে রি-র‍্যাংক করুন। এই পরামর্শটি সঠিক। এটি সেই বিকল্পটি এড়িয়ে গেছে যা হিসাব বদলে দিতে পারে, আর তা হলো আপনার ইতিমধ্যে ভাড়া করা সার্ভারেই embedding model চালানো।

ডলারের পরিবর্তে টোকেন গণনা করুন, কারণ প্রাইস লিস্ট পরিবর্তিত হলেও টোকেন সংখ্যা অপরিবর্তিত থাকে। ধরুন, 100,000 চাংকের একটি কর্পাস আছে যেখানে প্রতিটিতে 400 টোকেন, এতে 10,000টি প্রশ্ন করা হয়েছে, প্রতি উত্তরের জন্য 8টি চাংক মডেলে পাঠানো হয়েছে, 100 টোকেনের প্রশ্ন ও ইন্সট্রাকশন ব্লক রয়েছে এবং 400 টোকেনের উত্তর পাওয়া যাচ্ছে।

ChartToken load for a 100,000 chunk corpus and 10,000 questions
The data behind this chart
[
  {
    "label": "Embed the corpus (once)",
    "tokens_millions": 40,
    "tokens_per_question": "4,000"
  },
  {
    "label": "Embed each question",
    "tokens_millions": 0.2,
    "tokens_per_question": "20"
  },
  {
    "label": "Generation input",
    "tokens_millions": 33,
    "tokens_per_question": "3,300"
  },
  {
    "label": "Generation output",
    "tokens_millions": 4,
    "tokens_per_question": "400"
  }
]

পুরো কর্পাস এমবেড করতে 40 মিলিয়ন টোকেন লাগে এবং এটি একবারই করতে হয়। সেই 10,000 প্রশ্নের ওপর ভাগ করলে এটি প্রতি প্রশ্নে 4,000 টোকেন হয়। এক লক্ষ প্রশ্ন করলে এটি 400-তে নেমে আসে। জেনারেশনের খরচ কখনোই কমে না। এটি প্রতিটি উত্তরের জন্য 3,300 টোকেন ইনপুট এবং 400 টোকেন আউটপুট খরচ করে।

সুতরাং, খরচ সেই ধাপেই হয় যা বারবার পুনরাবৃত্তি হয়। এমবেডিং ধাপটি নিজের নিয়ন্ত্রণে রাখুন, কারণ এর জন্য আপনি একবারই খরচ করছেন এবং VPS এমনিতেই চলছে। জেনারেশন ধাপটি কিনে নিন, কারণ সেখানেই একটি উন্নত মডেলের জন্য প্রকৃত অর্থ ব্যয় করা সার্থক। ক্যাশিং একই কারণে গুরুত্বপূর্ণ: একটি ক্যাশ হিট সেই ধাপটিকে এড়িয়ে যায় যার খরচ কখনোই কমে না। KV cache এবং prompt cache-এর মধ্যে পার্থক্য নির্ধারণ করে যে আপনি এর কোন অর্ধেকটি পুনরায় ব্যবহার করতে পারবেন, এবং একটি RAG প্রম্পটে একটি স্থিতিশীল ইন্সট্রাকশন ব্লক থাকে যার পরে একটি পরিবর্তনশীল চাংক ব্লক থাকে, যা এই পদ্ধতির জন্য সবচেয়ে বেশি কার্যকর।

Chunking: কেন fixed size এবং overlap ডিফল্ট হিসেবে সঠিক

একটি chunk হলো সেই একক যা আপনি retrieve করেন, তাই এর আকার পরবর্তী সবকিছুর ওপর প্রভাব ফেলে। এটি যথেষ্ট ছোট হতে হবে যাতে এর embedding শুধুমাত্র একটি নির্দিষ্ট বিষয়ের ওপর ভিত্তি করে তৈরি হয়। কারণ একটি embedding স্পেসের একটি মাত্র বিন্দু নির্দেশ করে, তাই একটি chunk যদি চারটি ভিন্ন বিষয় কভার করে, তবে সেটি কোনোটিরই কাছাকাছি থাকবে না। আবার এটি যথেষ্ট বড় হতে হবে যাতে এটি নিজেই একটি প্রশ্নের উত্তর দিতে পারে, কারণ language model শুধুমাত্র chunk-টিকে দেখে, এর আশেপাশের পুরো ডকুমেন্টটিকে নয়।

300 শব্দের chunk এবং 50 শব্দের overlap দিয়ে শুরু করুন। ইংরেজিতে সাধারণত প্রতি শব্দে প্রায় 1.3 tokens থাকে, তাই 300 শব্দ মানে প্রায় 400 tokens। Overlap থাকার কারণ হলো, কোনো বাক্য যদি সীমানার ওপর পড়ে, তবে তা দুই ভাগে বিভক্ত হয়ে যায় এবং কোনো অংশই প্রশ্নের সঠিক উত্তর দিতে পারে না।

ডকুমেন্টে গঠন বা structure থাকলে প্রথমে সেই অনুযায়ী split করুন। Heading অনুযায়ী ভাঙুন, তারপর paragraph অনুযায়ী, এবং শুধুমাত্র সেই section-গুলোর ভেতরেই fixed size নিয়মটি প্রয়োগ করুন যা এখনো অনেক বড়। একটি chunk যদি বাক্যের মাঝখান থেকে শুরু হয়, তবে চূড়ান্ত উত্তরে তা পড়তে খারাপ শোনায়, কারণ model ঠিক সেই অংশটিই উদ্ধৃত করে যা আপনি তাকে দিয়েছেন।

পরিমাপ করার ক্ষমতা অর্জনের আগে chunking টিউন করবেন না। Fixed size এবং overlap পদ্ধতিটি deterministic এবং এটি পুনরায় চালানো সাশ্রয়ী, যা এটিকে একটি baseline হিসেবে প্রতিষ্ঠিত করে যাকে আপনি পরবর্তীতে উন্নত করতে পারবেন। প্রথমে scoring query তৈরি করুন, তারপর একটি একটি করে পরিবর্তন করুন।

একই বক্সে এমবেডিং এবং এর র‍্যাম ও ল্যাটেন্সি খরচ

curl -fsSL https://ollama.com/install.sh | sh
ollama pull nomic-embed-text

nomic-embed-text হলো 137 মিলিয়ন প্যারামিটারের একটি মডেল এবং 2026 সালের আগস্ট পর্যন্ত এর সাইজ 274 MB। এর আউটপুট কী আসে তা যাচাই করে তবেই এর জন্য টেবিল ডিজাইন করুন।

curl -s http://127.0.0.1:11434/api/embed \
  -d '{"model": "nomic-embed-text", "input": "search_document: hello"}' |
  python3 -c 'import json,sys; print(len(json.load(sys.stdin)["embeddings"][0]))'

এটি 768 প্রিন্ট করে। আপনার কলামের ধরন অবশ্যই এই সংখ্যার সাথে হুবহু মিলতে হবে।

এই মডেলের দুটি সেটিংস ব্যবহারকারীদের বিভ্রান্ত করে।

টাস্ক প্রিফিক্স ঐচ্ছিক নয়। Nomic-এর মডেল কার্ড অনুযায়ী ইনপুটে অবশ্যই একটি "টাস্ক ইনস্ট্রাকশন প্রিফিক্স" থাকতে হবে। ডকুমেন্ট এমবেড করার সময় সামনে search_document: এবং প্রশ্ন এমবেড করার সময় search_query: যোগ করতে হয়। এগুলো বাদ দিলে কোনো এরর দেখাবে না: আপনি ভেক্টর পাবেন ঠিকই, কিন্তু রিট্রিভাল কোয়ালিটি কমে যাবে এবং কোনো লগ লাইনেও এর কারণ লেখা থাকবে না।

দীর্ঘ ইনপুট নীরবে ট্রাঙ্কেট (truncate) করা হয়। /api/embed এন্ডপয়েন্ট একটি truncate ফিল্ড গ্রহণ করে যার ডিফল্ট মান true, এবং Ollama-এর প্যাকেজ করা মডেলটি 2K কন্টেক্সট সাপোর্ট করে। এর চেয়ে বড় কোনো চাঙ্ক (chunk) এই সীমার পর কেটে ফেলা হয় এবং এমবেড করা হয়, ফলে এর শেষ অংশটি আর সার্চ করা যায় না। পরীক্ষার সময় "truncate": false পাঠান, যাতে বড় চাঙ্ক হলে তা পাস না হয়ে এরর দেখায়।

রিকোয়েস্টগুলো ব্যাচ করুন এবং মডেলটিকে মেমরিতে সচল রাখুন।

curl -s http://127.0.0.1:11434/api/embed -d '{
  "model": "nomic-embed-text",
  "input": ["search_document: first chunk", "search_document: second chunk"],
  "keep_alive": "30m"
}' > /dev/null

input একটি লিস্ট গ্রহণ করে। 32টি আলাদা রিকোয়েস্টের চেয়ে 32টি চাঙ্কসহ একটি রিকোয়েস্ট পাঠানো অনেক বেশি কার্যকর, কারণ এতে HTTP রাউন্ড ট্রিপ এবং মডেল লুকআপ একবারই হয়। keep_alive নিয়ন্ত্রণ করে রিকোয়েস্টের পর মডেলটি কতক্ষণ মেমরিতে থাকবে, যার ডিফল্ট সময় 5 মিনিট। এটি এক্সপায়ার হয়ে গেলে পরবর্তী রিকোয়েস্টের সময় আবার লোড টাইম খরচ হয়।

আপনার নিজের বক্সে গুরুত্বপূর্ণ দুটি সংখ্যা পরিমাপ করুন। এগুলো আপনার vCPU সংখ্যার ওপর নির্ভর করে, তাই অন্য কোথাও প্রকাশিত সংখ্যা আপনার সার্ভারের সাথে মিলবে না।

ollama ps
time curl -s http://127.0.0.1:11434/api/embed \
  -d '{"model":"nomic-embed-text","input":"search_document: ... one real chunk ..."}' > /dev/null

ollama ps লোড হওয়া মডেলের রেসিডেন্ট সাইজ দেখায়, যা হলো সেই র‍্যাম যা keep_alive যতক্ষণ মডেলটিকে ধরে রাখবে ততক্ষণ বরাদ্দ থাকবে। time আউটপুটকে ব্যাচ সাইজ দিয়ে ভাগ করলে আপনি প্রতি চাঙ্কের জন্য প্রয়োজনীয় সেকেন্ড পাবেন। মোট চাঙ্ক সংখ্যা দিয়ে গুণ করলে আপনি একবার ইনডেক্সিং করার মোট খরচ পাবেন। শুধুমাত্র CPU-ভিত্তিক প্ল্যানে 1,00,000 চাঙ্কের একটি কর্পাস ইনডেক্স করতে মিনিটের বদলে কয়েক ঘণ্টা সময় লাগতে পারে। এটি স্বাভাবিক, কারণ এটি একবারই করতে হয় এবং nice -n 19 ব্যবহার করে রাতে চালানো সম্ভব। যদি কয়েক ঘণ্টা সময় নেওয়া সম্ভব না হয়, তবে আসল প্রশ্ন হলো GPU ভাড়া করা লাভজনক কি না, এবং এটি মূলত API টোকেনের সাথে খরচের তুলনা করার বিষয়, পছন্দের বিষয় নয়।

যদি বক্সে আগে থেকেই কোনো চ্যাট মডেল চলতে থাকে, তবে এমবেডিং মডেলটি দ্বিতীয় রেসিডেন্ট মডেল হিসেবে কাজ করবে এবং র‍্যামের ব্যবহার বেড়ে যাবে। Ollama on a VPS গাইডে জেনারেশন সাইডের সাইজিং এবং concurrent users-এর অধীনে সেলফ-হোস্টেড মডেলের আচরণ অংশে একাধিক ব্যবহারকারী একসাথে রিকোয়েস্ট করলে কী হয় তা আলোচনা করা হয়েছে। এমবেডিং মডেলটি যথেষ্ট ছোট, তাই এটি অন্য মডেলের পাশাপাশি অনায়াসেই চলতে পারে।

ইনডেক্সিং স্ক্রিপ্ট, শুরু থেকে শেষ পর্যন্ত

Ubuntu 24.04-এ ভার্চুয়াল এনভায়রনমেন্টের বাইরে সরাসরি pip install চালালে তা error: externally-managed-environment এরর দিয়ে বন্ধ হয়ে যায়, কারণ সিস্টেম পাইথনটি apt-এর নিয়ন্ত্রণে থাকে।

python3 -m venv ~/rag
~/rag/bin/pip install "psycopg[binary]" pgvector
import json, urllib.request
import psycopg
from pgvector.psycopg import register_vector
from pgvector import Vector

OLLAMA = "http://127.0.0.1:11434/api/embed"
MODEL = "nomic-embed-text"

def embed(texts, prefix="search_document: "):
    payload = {"model": MODEL,
               "input": [prefix + t for t in texts],
               "truncate": False,
               "keep_alive": "30m"}
    req = urllib.request.Request(OLLAMA, data=json.dumps(payload).encode(),
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req) as resp:
        return json.load(resp)["embeddings"]

def split(text, size=300, overlap=50):
    words = text.split()
    step = size - overlap
    return [" ".join(words[i:i + size]) for i in range(0, len(words), step)]

with psycopg.connect("dbname=rag user=rag") as conn:
    register_vector(conn)
    for doc_id, text in documents():          # your loader
        pieces = split(text)
        for start in range(0, len(pieces), 32):
            batch = pieces[start:start + 32]
            vectors = embed(batch)
            with conn.cursor() as cur:
                cur.executemany(
                    "INSERT INTO chunks (doc_id, seq, body, embedding)"
                    " VALUES (%s, %s, %s, %s)",
                    [(doc_id, start + i, body, Vector(vec))
                     for i, (body, vec) in enumerate(zip(batch, vectors))])
        conn.commit()

documents() আপনার নিজের তৈরি করতে হবে: যা আপনার ফাইল বা রো (row) থেকে ডকুমেন্ট আইডি এবং টেক্সট সংগ্রহ করবে। বাকি সবকিছু পাইপলাইনের অংশ।

স্টোরেজ: pgvector স্কিমা এবং এর আকার

Ubuntu 24.04-এ postgresql-16-pgvector এর 0.6.0 সংস্করণ থাকে, যা halfvec টাইপের চেয়ে পুরনো। বর্তমান বিল্ডের জন্য PostgreSQL প্রজেক্টের নিজস্ব রিপোজিটরি ব্যবহার করুন।

sudo apt update && sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install -y postgresql-17 postgresql-17-pgvector

প্যাকেজের নামের সংখ্যাটি আপনার সার্ভারের মেজর ভার্সনের সাথে মিলতে হবে। এরপর রোল, ডাটাবেস এবং এক্সটেনশন তৈরি করুন।

sudo -u postgres createuser --pwprompt rag
sudo -u postgres createdb --owner rag rag
sudo -u postgres psql -d rag -c 'CREATE EXTENSION vector;'
CREATE TABLE chunks (
  id        bigserial PRIMARY KEY,
  doc_id    text NOT NULL,
  seq       int  NOT NULL,
  body      text NOT NULL,
  embedding vector(768) NOT NULL,
  fts       tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED
);

CREATE INDEX chunks_fts ON chunks USING gin (fts);

vector(768)-কে অবশ্যই মডেলের আউটপুটের সাথে মিলতে হবে। যদি আপনি সেই কলামে 1024 ডাইমেনশনের ভেক্টর ইনসার্ট করেন, তবে Postgres তা প্রত্যাখ্যান করবে এবং expected 768 dimensions, not 1024 এরর দেখাবে, যা এই পুরো পাইপলাইনের সবচেয়ে স্পষ্ট এরর মেসেজ। তৈরি করা fts কলামটি রক্ষণাবেক্ষণে কোনো বাড়তি খরচ নেই এবং এটি পরবর্তীতে কিওয়ার্ড সার্চের সুবিধা দেয়।

স্টোরেজ হিসাবটি গাণিতিক। pgvector-এর ডকুমেন্টেশন অনুযায়ী একটি vector হলো 4 * dimensions + 8 বাইট এবং একটি halfvec হলো 2 * dimensions + 8। নিচের ডাইমেনশন সংখ্যাগুলো প্রতিটি মডেলের প্রকাশিত আউটপুট সাইজ।

ChartVector column size per 100,000 chunks, by embedding dimension
The data behind this chart
[
  {
    "label": "384 (all-minilm)",
    "bytes_per_vector": "1,544",
    "vector_mib_per_100k": 147,
    "halfvec_mib_per_100k": 74
  },
  {
    "label": "768 (nomic-embed-text)",
    "bytes_per_vector": "3,080",
    "vector_mib_per_100k": 294,
    "halfvec_mib_per_100k": 147
  },
  {
    "label": "1024 (mxbai-embed-large)",
    "bytes_per_vector": "4,104",
    "vector_mib_per_100k": 391,
    "halfvec_mib_per_100k": 196
  },
  {
    "label": "1536 (hosted API model)",
    "bytes_per_vector": "6,152",
    "vector_mib_per_100k": 587,
    "halfvec_mib_per_100k": 294
  }
]

768 ডাইমেনশনে প্রতিটি ভেক্টর 3,080 বাইট, তাই 1,00,000 চাঙ্ক {{q:vector_mib_per_100k@1}} MiB ভেক্টর ডাটা ধারণ করে। 1536 ডাইমেনশনের হোস্ট করা মডেল দ্বারা এমবেড করা একই কর্পাসের জন্য {{q:vector_mib_per_100k@-1}} MiB প্রয়োজন এবং এর ওপরের ইনডেক্স সেই অনুপাতে বৃদ্ধি পায়। হাফ প্রিসিশন উভয়কেই অর্ধেক করে দেয়: halfvec(768) সেই কর্পাসকে {{q:vector_mib_per_100k@1}} MiB-এ সংরক্ষণ করে। এটি রিকল (recall)-এ কোনো প্রভাব ফেলে কি না, তা নিচের স্কোরিং কুয়েরিটি একবার চালালেই বোঝা যাবে।

এই হিসাবগুলো শুধুমাত্র ভেক্টর কলামের জন্য। টেক্সট, রো ওভারহেড এবং ইনডেক্সগুলো এর অতিরিক্ত হিসেবে থাকে, তাই প্রকৃত টেবিলের আকার মেপে দেখুন।

SELECT pg_size_pretty(pg_total_relation_size('chunks')) AS total,
       pg_size_pretty(pg_relation_size('chunks'))       AS heap,
       count(*) AS n_rows
FROM chunks;

আপনি যদি একই এক্সটেনশন API এবং ইউজার অ্যাকাউন্টসহ পেতে চান, তবে একটি সেলফ-হোস্টেড Supabase স্ট্যাক ব্যবহার করতে পারেন, যেখানে Postgres-এ pgvector আগে থেকেই এনাবল করা থাকে এবং এই গাইডের প্রতিটি কুয়েরি সেখানে কোনো পরিবর্তন ছাড়াই কাজ করবে।

ইনডেক্সিং: HNSW সেটিংস যা গুরুত্বপূর্ণ

কয়েক হাজার সারির নিচে ইনডেক্স ব্যবহার করবেন না। এই আকারের ডেটাসেটে Exact search প্রতিটি সারি পড়ে ফেলে, যা যথেষ্ট দ্রুত এবং এর recall নিখুঁত। যখন sequential scan আর দ্রুত কাজ করে না, তখন ইনডেক্স যোগ করুন। মনে রাখবেন, একটি approximate ইনডেক্স আনুমানিক সঠিক প্রতিবেশী (neighbours) খুঁজে দেয়।

SET maintenance_work_mem = '2GB';
SET max_parallel_maintenance_workers = 3;
CREATE INDEX chunks_embedding ON chunks
  USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);

m = 16 এবং ef_construction = 64 হলো pgvector-এর ডিফল্ট সেটিংস। এগুলো বাড়ালে recall উন্নত হয়, কিন্তু ইনডেক্স তৈরির সময় এবং আকার বেড়ে যায়। যদি না আপনি নিশ্চিত হন যে আপনার মডেল unit length ভেক্টর তৈরি করে, তবে <=> অপারেটরের সাথে vector_cosine_ops ব্যবহার করুন। কারণ cosine distance ভেক্টরের দৈর্ঘ্য উপেক্ষা করে, কিন্তু inner product তা করে না।

ইনডেক্স তৈরির প্রক্রিয়াটি পর্যবেক্ষণ করুন। যখন গ্রাফ maintenance_work_mem-এর চেয়ে বড় হয়ে যায়, pgvector তা জানিয়ে দেয়:

NOTICE:  hnsw graph no longer fits into maintenance_work_mem after 100000 tuples
DETAIL:  Building will take significantly more time.

এটি কোনো ত্রুটি নয় এবং ইনডেক্স তৈরির কাজ শেষ হয়, তবে এটি অনেক ধীরগতির একটি প্রক্রিয়ায় চলে যায়। ইনডেক্স তৈরির সেশনে maintenance_work_mem-এর মান বাড়ান এবং সার্ভারের ডিফল্ট সেটিংস অপরিবর্তিত রাখুন। কারণ এই সেটিংটি প্রতিটি maintenance অপারেশনের জন্য প্রযোজ্য এবং গ্লোবাল মান খুব বেশি হলে সার্ভারের মেমোরি শেষ হয়ে যেতে পারে। দ্বিতীয় একটি সেশন থেকে দীর্ঘস্থায়ী ইনডেক্স তৈরির প্রক্রিয়াটি অনুসরণ করুন।

SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS "%"
FROM pg_stat_progress_create_index;

এরপর তৈরি হওয়া ইনডেক্সের আকারের সাথে সার্ভারের মেমোরির তুলনা করুন।

SELECT pg_size_pretty(pg_relation_size('chunks_embedding'));
SHOW shared_buffers;

একটি HNSW সার্চ গ্রাফের মধ্য দিয়ে যায়, তাই এটি ইনডেক্সের বিভিন্ন জায়গায় ছড়িয়ে থাকা পেজগুলো অ্যাক্সেস করে, কোনো নির্দিষ্ট রেঞ্জ পড়ার পরিবর্তে। যে ইনডেক্স মেমোরিতে ধরে না, তা প্রতিটি কুয়েরিকে ডিস্ক রিডে পরিণত করে এবং ব্যবহারকারীরা এই ধীরগতিই অনুভব করেন। সার্ভারের সাইজিংয়ের জন্য একটিই নিয়ম: ইনডেক্স এবং আপনি যে সারিগুলো সার্ভ করছেন, তার সমষ্টি অবশ্যই RAM-এর মধ্যে থাকতে হবে। free -m এবং উপরের আকার—এই দুটি সংখ্যার তুলনা করুন।

কুয়েরি করার সময়, hnsw.ef_search হলো recall নিয়ন্ত্রণ করার ডায়াল এবং এর ডিফল্ট মান 40।

BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT id, body FROM chunks ORDER BY embedding <=> $1 LIMIT 8;
COMMIT;

উচ্চতর মান গ্রাফের আরও বেশি অংশ সার্চ করে, ভালো প্রতিবেশী খুঁজে পায় এবং ল্যাটেন্সি বাড়ায়। এটি একটি সেশন সেটিং, তাই ইনডেক্সে কোনো পরিবর্তন না করেই আপনি একটি নির্দিষ্ট কুয়েরির জন্য এটি বাড়াতে পারেন।

যদি কোনো কুয়েরি ইনডেক্স ব্যবহার না করে, তবে প্ল্যানে তা দেখা যায়।

EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM chunks ORDER BY embedding <=> $1 LIMIT 8;

এখানে sequential scan হওয়ার কারণ প্রায়শই স্টোরেজ। একটি 768 ডাইমেনশন ভেক্টর 3,080 বাইট জায়গা নেয়, যা Postgres-এর ইনলাইন ধারণক্ষমতার চেয়ে বেশি। তাই এই মান TOAST টেবিলে (অতিরিক্ত বড় মানের জন্য আউট-অফ-লাইন স্টোর) চলে যায়। pgvector-এর নিজস্ব নোট অনুযায়ী, প্ল্যানার তার খরচ নির্ধারণের সময় আউট-অফ-লাইন স্টোরেজ গণনা করে না, যা একটি serial scan-কে প্রয়োজনের চেয়ে সস্তা দেখাতে পারে। ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; ভেক্টরগুলোকে ইনলাইনে রাখে। এটি পরিবর্তনের পর লেখা সারিগুলোর ক্ষেত্রে প্রযোজ্য, তাই বিদ্যমান সারিগুলোর জন্য টেবিলটি পুনরায় লিখতে (rewrite) হবে।

Retrieval: একটি কুয়েরি, দুটি সিগন্যাল

Vector search এমন টেক্সট খুঁজে বের করে যার অর্থ প্রশ্নের সাথে মিলে যায়। এটি নির্দিষ্ট স্ট্রিং যেমন পার্ট নম্বর, এরর কোড বা পদবি খোঁজার ক্ষেত্রে দুর্বল। Keyword search ঠিক এর বিপরীত কাজ করে এবং Postgres-এ এটি আগে থেকেই বিদ্যমান। দ্বিতীয় কোনো সিস্টেম ব্যবহার না করে একটি কুয়েরিতেই উভয় পদ্ধতিকে একত্রিত করুন।

Reciprocal rank fusion হলো সবচেয়ে সহজ এবং কার্যকর সমন্বয়কারী পদ্ধতি। প্রতিটি রেজাল্ট যে তালিকাগুলোতে থাকে, সেখান থেকে 1 / (60 + rank) গ্রহণ করে এবং দুটি স্কোর যোগ করা হয়। এতে স্কোর নরমালাইজেশনের প্রয়োজন হয় না, কারণ এটি দূরত্বের পরিবর্তে অবস্থান (position) বিবেচনা করে।

WITH semantic AS (
  SELECT id, row_number() OVER (ORDER BY distance) AS rank
  FROM (SELECT id, embedding <=> $1 AS distance
        FROM chunks ORDER BY embedding <=> $1 LIMIT 40) s
),
keyword AS (
  SELECT id, row_number() OVER (ORDER BY score DESC) AS rank
  FROM (SELECT c.id, ts_rank_cd(c.fts, q) AS score
        FROM chunks c, websearch_to_tsquery('english', $2) q
        WHERE c.fts @@ q
        ORDER BY score DESC LIMIT 40) k
)
SELECT c.id, c.body,
       coalesce(1.0 / (60 + s.rank), 0) + coalesce(1.0 / (60 + k.rank), 0) AS rrf
FROM (SELECT id FROM semantic UNION SELECT id FROM keyword) u
JOIN chunks c ON c.id = u.id
LEFT JOIN semantic s ON s.id = u.id
LEFT JOIN keyword  k ON k.id = u.id
ORDER BY rrf DESC
LIMIT 8;

$1 হলো একই মডেল থেকে তৈরি প্রশ্নের এমবেডিং, যা search_query: প্রিফিক্স দিয়ে তৈরি। $2 হলো টেক্সট হিসেবে প্রশ্নটি। উভয়ই আপনার অ্যাপ্লিকেশন থেকে বাইন্ড করা হয়। websearch_to_tsquery কোনো যতিচিহ্নের কারণে বাধা না পেয়ে সরাসরি ব্যবহারকারীর প্রশ্ন গ্রহণ করতে পারে, যা to_tsquery পারে না। আরও একটি বিষয় মনে রাখা প্রয়োজন: HNSW স্ক্যানের ওপর WHERE ফিল্টার যোগ করলে আপনার চাহিদার চেয়ে কম সারি (row) পাওয়া যেতে পারে, কারণ ইনডেক্স আগে সার্চ হয় এবং ফিল্টার পরে কাজ করে। SET hnsw.iterative_scan = relaxed_order; ব্যবহার করলে pgvector পর্যাপ্ত সারি না পাওয়া পর্যন্ত স্ক্যান চালিয়ে যায়।

রিট্রিভাল বা তথ্য পুনরুদ্ধার কতটা কার্যকর তা কীভাবে বুঝবেন?

এটি এমন একটি ধাপ যা প্রায় প্রতিটি RAG নির্দেশিকায় বাদ দেওয়া হয়, অথচ এটিই একমাত্র উপায় যা আপনাকে নিশ্চিত করবে যে আপনার নেওয়া অন্যান্য সিদ্ধান্তগুলো কাজে আসছে কি না। এর জন্য কোনো জটিল মূল্যায়ন কাঠামোর প্রয়োজন নেই। আপনার প্রয়োজন 30টি প্রশ্ন এবং প্রতিটি প্রশ্নের উত্তর যে চাঙ্কে (chunk) রয়েছে তার আইডি (id)।

এগুলো হাতে লিখুন। আপনার কর্পাস (corpus) সম্পর্কে মানুষ সচরাচর যে প্রশ্নগুলো করে সেগুলো নিন, প্রতিটি প্রশ্ন রান করুন, কী ফলাফল আসছে তা পড়ুন এবং যে চাঙ্কটি আসার কথা ছিল তার আইডি রেকর্ড করুন। 30টি প্রশ্ন ছোটখাটো পার্থক্যগুলো ধরতে পারবে না। তবে এটি গুরুত্বপূর্ণ পার্থক্যগুলো ঠিকই ধরবে, কারণ সেগুলো বেশ বড়।

CREATE TABLE gold (
  id        bigserial PRIMARY KEY,
  question  text   NOT NULL,
  chunk_id  bigint NOT NULL REFERENCES chunks(id),
  embedding vector(768) NOT NULL
);

প্রতিটি প্রশ্নকে search_query: প্রিফিক্স দিয়ে এমবেড করুন, সংরক্ষণ করুন এবং তারপর পুরো সেটটিকে একটি কুয়েরিতে স্কোর করুন।

WITH hits AS (
  SELECT g.id,
         min(r.rank) FILTER (WHERE r.id = g.chunk_id) AS hit_rank
  FROM gold g
  CROSS JOIN LATERAL (
    SELECT top.id, row_number() OVER (ORDER BY top.distance) AS rank
    FROM (SELECT c.id, c.embedding <=> g.embedding AS distance
          FROM chunks c
          ORDER BY c.embedding <=> g.embedding
          LIMIT 10) top
  ) r
  GROUP BY g.id
)
SELECT count(*)         AS questions,
       count(hit_rank)  AS found_in_top_10,
       round(avg(coalesce(1.0 / hit_rank, 0)), 3) AS mrr
FROM hits;

found_in_top_10-কে questions দিয়ে ভাগ করলে পাওয়া যায় recall at 10; অর্থাৎ আপনি মডেলের কাছে যে উইন্ডো পাঠাচ্ছেন তার মধ্যে কতবার সঠিক উত্তরটি ছিল। MRR (mean reciprocal rank) সঠিক চাঙ্কের অবস্থানের বিপরীত মানের গড় বের করে এবং মিস হলে শূন্য ধরে। তাই এটি অষ্টম অবস্থানের চেয়ে প্রথম অবস্থানে উত্তরটিকে রাখলে বেশি গুরুত্ব দেয়। আপনি যখন চাঙ্কের আকার পরিবর্তন করেন, এমবেডিং মডেল অদলবদল করেন বা কিওয়ার্ড সার্চ যোগ করেন, তখন এই উভয় সংখ্যাই পরিবর্তিত হয় এবং এখন আপনি দেখতে পাবেন সেগুলো কোন দিকে পরিবর্তিত হচ্ছে।

অন্য সবকিছুর উপরে recall at 10-কে গুরুত্ব দিন, কারণ মডেল যে চাঙ্কটি পায়নি তা ব্যবহার করতে পারে না। যখন recall at 10 এর মান 0.9 হয় এবং তবুও উত্তর ভুল আসে, তখন বুঝতে হবে সমস্যাটি প্রম্পট বা মডেলে, রিট্রিভালে নয়। এই একটি বিভাজন আপনাকে কয়েক দিনের অনুমাননির্ভর কাজ থেকে বাঁচাবে।

ইনডেক্সটি আলাদাভাবে পরীক্ষা করুন। আনুমানিক বা অ্যাপ্রক্সিমেট সার্চে রিকল কমে যায় এবং pgvector আপনাকে দেখাবে ঠিক কতটা কমেছে: একই কুয়েরি একবার এক্সাক্ট সার্চ দিয়ে রান করুন এবং আইডিগুলো তুলনা করুন।

BEGIN;
SET LOCAL enable_indexscan = off; -- use exact search
SELECT id FROM chunks ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;

দশটির মধ্যে নয়টি আইডি মিলে গেলে বুঝতে হবে ef_search ঠিক আছে। দশটির মধ্যে চারটি মিললে এর মান বাড়াতে হবে।

Reranking এবং generation: যেখানে একটি API অর্থ উপার্জন করে

একটি reranker হলো ভিন্ন ধরনের মডেল। এটি প্রশ্ন এবং একটি chunk একসাথে পড়ে এবং সেই জোড়ার স্কোর নির্ধারণ করে, যা স্বাধীনভাবে গণনা করা দুটি embedding-এর তুলনা করার চেয়ে কার্যকর। তবে পুরো corpus-এর ওপর এটি চালানো অত্যন্ত ধীরগতির। ঠিক এই কারণেই এটি এখানে ব্যবহৃত হয়। এটি retrieval থেকে প্রাপ্ত 40টি candidate দেখে, পুরো টেবিলের 100,000 chunk নয়। তাই একটি hosted reranking API প্রতি প্রশ্নের জন্য 40টি ছোট জোড়ার ওপর চার্জ করে এবং ব্যয়বহুল পর্যায়ে যাওয়ার আগেই সবচেয়ে বাজে false positive-গুলোকে বাদ দিয়ে দেয়।

Generation হলো নিয়মিত খরচের জায়গা এবং দুটি উপায়ে এটি নিয়ন্ত্রণ করা যায়। কম chunk পাঠান; recall at 10 ব্যবহার করে দেখুন উত্তরের মান না কমিয়ে কত কম chunk পাঠানো সম্ভব। prompt-এর শুরুর অংশটি বাইট-বাইটে স্থিতিশীল রাখুন যাতে প্রোভাইডারের prompt cache তা ধরতে পারে এবং retrieved chunk-গুলোকে সেই স্থিতিশীল অংশের পরে রাখুন। প্রশ্ন অনুযায়ী সম্পন্ন হওয়া উত্তরগুলোও cache করুন, কারণ সবচেয়ে সাশ্রয়ী generated token হলো সেটি যা আপনি গত সপ্তাহে তৈরি করেছিলেন।

সার্ভারের আকার নির্ধারণ এবং কখন এটি আর যথেষ্ট নয়

এখানে আকার নির্ধারণের প্রতিটি নিয়ম অনুমানের পরিবর্তে পরিমাপের ওপর ভিত্তি করে তৈরি।

  • RAM হলো প্রধান সীমাবদ্ধতা: ollama ps থেকে প্রাপ্ত রেসিডেন্ট মডেলের আকার, তার সাথে HNSW ইনডেক্সের আকার এবং shared_buffers যোগ করুন, সেই সাথে কানেকশন ও পেজ ক্যাশের জন্য কিছু বাড়তি জায়গা রাখুন।
  • ডিস্কের জন্য pg_total_relation_size('chunks')-এর দ্বিগুণ জায়গা প্রয়োজন, কারণ ইনডেক্স পুনর্নির্মাণের সময় দুটি কপিই একসাথে সংরক্ষিত থাকে।
  • CPU নির্ধারণ করে ইনডেক্স পুনর্নির্মাণের সময়, যা আপনার পরিমাপ করা প্রতি চাঙ্ক (chunk) সেকেন্ডের সাথে মোট চাঙ্ক সংখ্যা গুণ করে পাওয়া যায়।
  • ইনডেক্স পুনর্নির্মাণ আপনার প্রত্যাশার চেয়ে বেশি ঘন ঘন ঘটে, কারণ এম্বেডিং মডেল পরিবর্তন করলে আগে থেকে সংরক্ষিত প্রতিটি ভেক্টর অকার্যকর হয়ে যায়।

এই ডিজাইনটি কখন আর যথেষ্ট হবে না তা আপনি আগেই বুঝতে পারবেন। যখন HNSW ইনডেক্স আপনার কেনা RAM-এ আর ধরে না, তখন কুয়েরি ল্যাটেন্সি ডিস্ক সিক-এ পরিণত হয় এবং কোনো সেটিংই তা পুনরুদ্ধার করতে পারে না। যখন একটি টেবিল অনেকগুলো টেন্যান্টকে সেবা দেয় এবং প্রতিটি কুয়েরি টেন্যান্ট অনুযায়ী ফিল্টার করা হয়, তখন টেবিল পার্টিশনিং করাই একমাত্র সমাধান, যা বেশ জটিল কাজ। যখন ইনডেক্সিং রাইট এবং ব্যবহারকারীর কুয়েরি একই সার্ভারে প্রতিযোগিতা করে, তখন ডাটাবেস সরানোর আগে এম্বেডিং ওয়ার্কারকে দ্বিতীয় একটি সার্ভারে সরিয়ে নিন। যতক্ষণ না এর মধ্যে কোনো একটি পরিস্থিতি তৈরি হচ্ছে, ততক্ষণ আপনার বর্তমান VPS-এ Postgres এবং pgvector ব্যবহার করাই প্রোডাকশনের জন্য যথেষ্ট এবং উপরের সংখ্যাগুলো আপনাকে জানিয়ে দেবে যে সীমাবদ্ধতা কত দূরে রয়েছে।

FAQ

আমি কি একটি VPS-এ RAG pipeline চালাতে পারি, নাকি আমার একটি vector database প্রয়োজন?

কয়েক লক্ষ চাঙ্ক (chunks) বিশিষ্ট কর্পাসের জন্য একটি VPS-ই যথেষ্ট। 768 ডাইমেনশনে, 100,000 চাঙ্কের জন্য 294 MiB ভেক্টর ডেটা, সেই সাথে টেক্সট এবং HNSW ইনডেক্স প্রয়োজন হয়, যা সাধারণ প্ল্যানের RAM-এই ধরে যায়। এখানে সীমাবদ্ধতা হলো মেমোরি, রো (row) সংখ্যা নয়; কারণ HNSW সার্চ ইনডেক্সের বিভিন্ন জায়গায় জাম্প করে, তাই ইনডেক্স RAM-এ না ধরলে ল্যাটেন্সি বেড়ে যায়। ইনডেক্সের ওপর pg_relation_size-এর সাথে free -m তুলনা করুন, তাহলেই বুঝতে পারবেন আপনার অবস্থান কোথায়।

আমার ডকুমেন্ট এমবেড করার জন্য কি GPU প্রয়োজন?

যদি আপনি একবার এমবেড করে পরে কুয়েরি করেন, তবে প্রয়োজন নেই। 137 মিলিয়ন প্যারামিটারের মডেল যেমন nomic-embed-text CPU-তেই চলে, এবং বড় কর্পাসের ওপর একটি পূর্ণ পাস চালাতে কয়েক ঘণ্টা সময় লাগে যা আপনি রাতের বেলা সম্পন্ন করতে পারেন। GPU তখনই গুরুত্বপূর্ণ হয়ে ওঠে যখন ডকুমেন্টগুলো ক্রমাগত আসতে থাকে, অথবা আপনি একই বক্সে জেনারেশন চালাতে চান। আপনার সার্ভারে /api/embed-এর বিপরীতে একটি ব্যাচের সময় পরিমাপ করুন এবং সেটিকে আপনার চাঙ্ক সংখ্যা দিয়ে গুণ করুন, কারণ vCPU-এর সংখ্যা এত ভিন্ন যে কোনো প্রকাশিত পরিসংখ্যান এখানে কার্যকর হবে না।

আমার ভেক্টর কুয়েরি কেন HNSW ইনডেক্সের পরিবর্তে sequential scan ব্যবহার করছে?

EXPLAIN (ANALYZE, BUFFERS) দিয়ে প্ল্যানটি পড়ুন। এর সাধারণ কারণ হলো স্টোরেজ: pgvector উল্লেখ করে যে, প্ল্যানার তার খরচ অনুমানে (cost estimates) out-of-line স্টোরেজ গণনা করে না, যা একটি সিরিয়াল স্ক্যানকে তার প্রকৃত খরচের চেয়ে কম দেখায়। একটি 768 ডাইমেনশন ভেক্টর 3,080 বাইট হওয়ায় এটি ডিফল্টভাবে TOAST টেবিলে থাকে। ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; নতুন রো-গুলোকে ইনলাইনে রাখে। অন্য দুটি কারণ হলো এমন একটি অপারেটর যা ইনডেক্সের সাথে মেলে না (যেহেতু vector_cosine_ops দিয়ে তৈরি ইনডেক্স শুধুমাত্র <=> দ্বারা ব্যবহৃত হয়) এবং ORDER BY ... LIMIT ছাড়া কুয়েরি করা, কারণ একটি অ্যাপ্রক্সিমেট ইনডেক্স শুধুমাত্র অর্ডারড নিয়ারেস্ট নেইবার কুয়েরি সার্ভ করে।

আমি কীভাবে বুঝব আমার রিট্রিভাল (retrieval) ভালো হচ্ছে কি না?

30টি প্রশ্নের একটি গোল্ড সেট তৈরি করুন, যেখানে প্রতিটি প্রশ্নের সাথে উত্তর প্রদানকারী চাঙ্কের আইডি যুক্ত থাকবে এবং প্রশ্নগুলোর এমবেডিং পাশাপাশি সংরক্ষণ করুন। এরপর recall at 10 পরিমাপ করুন, যা নির্দেশ করে কত ঘনঘন সঠিক চাঙ্কটি শীর্ষ 10-এর মধ্যে থাকে, এবং MRR পরিমাপ করুন, যা সেটিকে প্রথম র‍্যাঙ্কে রাখার জন্য রিওয়ার্ড দেয়। এই দুটি সংখ্যাই আপনাকে জানাবে যে চাঙ্ক সাইজ, এমবেডিং মডেল বা র‍্যাঙ্ক ফিউশনে কোনো পরিবর্তন কার্যকর হয়েছে কি না। এগুলো ছাড়া আপনি কেবল সেটিংস পরিবর্তন করবেন এবং গুটিকয়েক উত্তরের ওপর ভিত্তি করে আপনার ধারণার ওপর নির্ভর করবেন।