নিজের VPS-এ RAG pipeline তৈরির সম্পূর্ণ গাইড
একটি VPS-এ কীভাবে RAG pipeline তৈরি করবেন তা জানুন। pgvector schema, HNSW ইনডেক্সিং, স্থানীয় embedding মডেল এবং কার্যকর retrieval নিশ্চিত করার SQL কোড এখানে পাবেন।
একটি self-hosted RAG pipeline দেখতে যেমন হয়
একটি RAG (retrieval augmented generation) pipeline পাঁচটি ধাপে কাজ করে: ডকুমেন্টগুলোকে chunk বা ছোট অংশে ভাগ করা, chunk-গুলোর embedding তৈরি করা, vector-গুলো সংরক্ষণ করা, প্রশ্নের সাথে মিল আছে এমন তথ্য খুঁজে বের করা এবং সেই chunk-গুলোকে একটি language model-এর কাছে পাঠানো যা উত্তর লিখে দেয়। আপনি যে VPS ভাড়া নিয়েছেন, তার মধ্যেই প্রথম চারটি ধাপ সম্পন্ন করা সম্ভব। PostgreSQL-এর pgvector extension ব্যবহার করে vector-গুলো জমা রাখা হয় এবং Ollama দ্বারা পরিচালিত একটি ছোট embedding model টেক্সটকে vector-এ রূপান্তর করে। শুধুমাত্র শেষ ধাপটির জন্য সার্ভারের বাইরে যাওয়ার প্রয়োজন হয়।
এই বিভাজনই হলো এই গাইডের মূল বিষয়। Chunking মূলত CPU-এর কাজ। Embedding হলো 137 মিলিয়ন প্যারামিটারের একটি মডেল, যা কয়েকশ মেগাবাইট RAM-এ অনায়াসেই চলে। স্টোরেজ হলো একটি Postgres টেবিল, যার আকার আপনি প্রথম সারি লেখার আগেই গাণিতিকভাবে হিসাব করে নিতে পারেন। কয়েক লক্ষ chunk-এর একটি corpus-এর জন্য এই সবকিছু একটি সাধারণ VPS-এই চালানো সম্ভব। Generation প্রক্রিয়াটি ভিন্ন, কারণ প্রতিটি প্রশ্নের জন্য এটি দীর্ঘমেয়াদে খরচ তৈরি করে।
RAG পাইপলাইনের কোন অংশগুলোতে খরচ হয়
DigitalOcean-এর end to end RAG tutorial-এ একটি ম্যানেজড ভেক্টর ডেটাবেস এবং একটি হোস্ট করা এম্বেডিং মডেল ব্যবহারের পরামর্শ দেওয়া হয়েছে। সেখানে খরচের বিষয়টি গুণগতভাবে আলোচনা করা হয়েছে: বারবার করা কুয়েরি ক্যাশ করা, রিট্রিভ করা চাংকের সংখ্যা কম রাখা এবং জেনারেশনের আগে রি-র্যাংক করা। এই পরামর্শটি সঠিক। তবে এটি এমন একটি বিকল্প এড়িয়ে গেছে যা হিসাব পুরোপুরি বদলে দিতে পারে, আর তা হলো আপনার বর্তমান সার্ভারেই এম্বেডিং মডেলটি চালানো।
ডলারের পরিবর্তে টোকেন গণনা করুন, কারণ প্রাইস লিস্ট পরিবর্তিত হলেও টোকেন সংখ্যা অপরিবর্তিত থাকে। ধরা যাক, আপনার কাছে 100,000 চাংকের একটি কর্পাস আছে যেখানে প্রতিটিতে 400 টোকেন রয়েছে। এর ওপর 10,000টি প্রশ্ন করা হলো, প্রতি উত্তরের জন্য 8টি চাংক মডেলে পাঠানো হলো, 100 টোকেনের একটি প্রশ্ন ও ইন্সট্রাকশন ব্লক রয়েছে এবং 400 টোকেনের উত্তর পাওয়া যাচ্ছে।
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-টি দেখে, এর চারপাশের পুরো document-টি নয়।
300 শব্দের chunk এবং 50 শব্দের overlap দিয়ে শুরু করুন। ইংরেজিতে সাধারণত প্রতি শব্দে প্রায় 1.3 tokens থাকে, তাই 300 শব্দ মানে প্রায় 400 tokens। Overlap রাখা হয় কারণ সীমানায় পড়ে যাওয়া কোনো বাক্য অন্যথায় অর্ধেক হয়ে যায়, এবং কোনো অর্ধেক অংশই প্রশ্নের সঠিক উত্তর দিতে পারে না।
যেসব document-এ কাঠামো আছে, সেখানে প্রথমে কাঠামোর ওপর ভিত্তি করে split করুন। Heading অনুযায়ী ভাগ করুন, তারপর paragraph অনুযায়ী, এবং fixed size-এর নিয়মটি কেবল তখনই প্রয়োগ করুন যখন কোনো section অতিরিক্ত বড় হয়। একটি chunk যদি বাক্যের মাঝখান থেকে শুরু হয়, তবে চূড়ান্ত উত্তরে তা পড়তে খারাপ শোনায়, কারণ model আপনাকে দেওয়া অংশটিই উদ্ধৃত করে।
পরিমাপ করার আগে chunking টিউন করবেন না। Fixed size এবং overlap পদ্ধতিটি deterministic এবং এটি পুনরায় চালানো সাশ্রয়ী, যা একে একটি baseline হিসেবে দাঁড় করায় যাকে আপনি পরে ছাড়িয়ে যেতে পারবেন। প্রথমে scoring query তৈরি করুন, তারপর একটি একটি করে পরিবর্তন আনুন।
একই বক্সে এমবেডিং এবং এর র্যাম ও ল্যাটেন্সি খরচ
curl -fsSL https://ollama.com/install.sh | sh
ollama pull nomic-embed-textnomic-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: যোগ করতে হয়। এগুলো বাদ দিলে কোনো এরর দেখাবে না: আপনি ভেক্টর পাবেন ঠিকই, কিন্তু রিট্রিভাল কোয়ালিটি কমে যাবে এবং কেন এমন হচ্ছে তা কোনো লগ লাইনে উল্লেখ থাকবে না।
দীর্ঘ ইনপুট নীরবে ট্রাঙ্কেট করা হয়। /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/nullinput একটি তালিকা গ্রহণ করে। 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/nullollama ps লোড হওয়া মডেলের রেসিডেন্ট সাইজ প্রিন্ট করে, যা হলো সেই র্যাম যা keep_alive যতক্ষণ মডেলটিকে ধরে রাখবে ততক্ষণ আপনার বরাদ্দ থাকবে। time আউটপুটকে ব্যাচ সাইজ দিয়ে ভাগ করলে আপনি প্রতি চাঙ্কের জন্য প্রয়োজনীয় সেকেন্ড পাবেন। মোট চাঙ্ক সংখ্যা দিয়ে গুণ করলে আপনি একবার ইনডেক্সিং করার খরচ জানতে পারবেন। শুধুমাত্র CPU-ভিত্তিক প্ল্যানে, 1,00,000 চাঙ্কের একটি কর্পাস ইনডেক্স করতে কয়েক ঘণ্টা সময় লাগতে পারে। এটি সমস্যা নয়, কারণ এটি একবারই করতে হয় এবং nice -n 19 ব্যবহার করে রাতে চালানো সম্ভব। যদি কয়েক ঘণ্টা সময় নেওয়া সম্ভব না হয়, তবে আসল প্রশ্ন হলো GPU ভাড়া করা লাভজনক কি না, এবং এটি API টোকেনের সাথে ব্রেক-ইভেন ক্যালকুলেশন করার বিষয়, পছন্দের বিষয় নয়।
যদি বক্সে আগে থেকেই কোনো চ্যাট মডেল চলতে থাকে, তবে এমবেডিং মডেলটি দ্বিতীয় রেসিডেন্ট মডেল হিসেবে কাজ করবে এবং র্যামের ব্যবহার বেড়ে যাবে। Ollama অন VPS গাইডে জেনারেশন সাইডের সাইজিং এবং সেলফ-হোস্টেড মডেলের কনকারেন্ট ইউজার পারফরম্যান্স গাইডে একাধিক ব্যবহারকারী একসাথে প্রশ্ন করলে কী ঘটে তা আলোচনা করা হয়েছে। এমবেডিং মডেলটি যথেষ্ট ছোট, তাই এটি অন্য মডেলের পাশাপাশি অনায়াসেই চলতে পারে।
ইনডেক্সিং স্ক্রিপ্ট, শুরু থেকে শেষ পর্যন্ত
Ubuntu 24.04-এ ভার্চুয়াল এনভায়রনমেন্টের বাইরে সাধারণ pip install চালালে তা error: externally-managed-environment এর কারণে বন্ধ হয়ে যায়, কারণ সিস্টেম পাইথনটি apt-এর নিয়ন্ত্রণে থাকে।
python3 -m venv ~/rag
~/rag/bin/pip install "psycopg[binary]" pgvectorimport 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() আপনার নিজের: যা আপনার ফাইল বা সারিগুলো স্ক্যান করবে এবং একটি ডকুমেন্ট আইডি ও তার টেক্সট প্রদান করবে। বাকি সবকিছুই হলো পাইপলাইন।
স্টোরেজ: 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। নিচের ডাইমেনশন সংখ্যাগুলো প্রতিটি মডেলের প্রকাশিত আউটপুট সাইজ।
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 বাইট, তাই 100,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 সেটিংস যা গুরুত্বপূর্ণ
কয়েক হাজার সারির নিচে ইনডেক্স এড়িয়ে চলুন। এক্সাক্ট সার্চ প্রতিটি সারি পড়ে, এই আকারের ডেটার জন্য এটি যথেষ্ট দ্রুত এবং এর রিকল (recall) নিখুঁত। যখন সিকোয়েনশিয়াল স্ক্যান যথেষ্ট দ্রুত কাজ করা বন্ধ করে দেয়, তখন ইনডেক্স যোগ করুন। তবে এই ট্রেড-অফটি মনে রাখবেন: একটি অ্যাপ্রক্সিমেট ইনডেক্স মোটামুটি সঠিক নেইবার (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-এর ডিফল্ট সেটিংস। এগুলো বৃদ্ধি করলে রিকল উন্নত হয়, তবে ইনডেক্স তৈরির সময় এবং আকার বেড়ে যায়। <=> অপারেটরের সাথে vector_cosine_ops ব্যবহার করুন, যদি না আপনি নিশ্চিত হন যে আপনার মডেল ইউনিট লেন্থ ভেক্টর তৈরি করে। কারণ কোসাইন ডিস্টেন্স ভেক্টরের দৈর্ঘ্য উপেক্ষা করে, কিন্তু ইনার প্রোডাক্ট তা করে না।
বিল্ড প্রক্রিয়া পর্যবেক্ষণ করুন। যখন গ্রাফটি 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 বৃদ্ধি করুন এবং সার্ভারের ডিফল্ট সেটিংস অপরিবর্তিত রাখুন। কারণ এই সেটিংটি প্রতিটি মেইনটেন্যান্স অপারেশনের জন্য প্রযোজ্য এবং গ্লোবাল ভ্যালু অনেক বেশি হলে সার্ভারের মেমোরি শেষ হয়ে যেতে পারে। দ্বিতীয় একটি সেশন থেকে দীর্ঘ বিল্ড প্রক্রিয়া অনুসরণ করুন।
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 হলো রিকল নিয়ন্ত্রণকারী ডায়াল এবং এর ডিফল্ট মান 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;এখানে সিকোয়েনশিয়াল স্ক্যান হওয়ার কারণ প্রায়শই স্টোরেজ। একটি 768 ডাইমেনশন ভেক্টর 3,080 বাইট জায়গা নেয়, যা Postgres-এর ইনলাইন ধারণক্ষমতার চেয়ে বেশি। তাই ভ্যালুটি TOAST টেবিলে (অতিরিক্ত বড় ডেটার জন্য আউট-অফ-লাইন স্টোর) চলে যায়। pgvector-এর নিজস্ব নোট অনুযায়ী, প্ল্যানার তার কস্ট এস্টিমেশনে আউট-অফ-লাইন স্টোরেজ গণনা করে না, যা একটি সিরিয়াল স্ক্যানকে প্রকৃত অবস্থার চেয়ে সস্তা দেখাতে পারে। ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; ভেক্টরগুলোকে ইনলাইনে রাখে। এটি পরিবর্তনের পরে লেখা সারিগুলোর ক্ষেত্রে প্রযোজ্য, তাই বিদ্যমান সারিগুলোর জন্য টেবিল রিরাইট করার প্রয়োজন হয়।
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) আছে তার আইডি।
এগুলো হাতে লিখুন। আপনার কর্পাস বা নথিপত্র সম্পর্কে মানুষ সাধারণত যে প্রশ্নগুলো করে সেগুলো নিন, প্রতিটি প্রশ্ন রান করুন, কী ফলাফল আসছে তা পড়ুন এবং যে চাঙ্কটি আসার কথা ছিল তার আইডি রেকর্ড করুন। 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 হয় এবং তবুও উত্তর ভুল আসে, তখন বুঝতে হবে সমস্যাটি প্রম্পটে বা মডেলে, রিট্রিভালে নয়। এই একটি বিভাজন আপনাকে কয়েক দিনের অনুমাননির্ভর কাজ থেকে বাঁচাবে।
ইনডেক্সটি আলাদাভাবে পরীক্ষা করুন। আনুমানিক বা approximate সার্চ রিট্রিভালের মান কমিয়ে দেয় এবং pgvector আপনাকে দেখাবে ঠিক কতটা কমেছে: একই কোয়েরি exact সার্চ দিয়ে রান করুন এবং আইডিগুলো তুলনা করুন।
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 দেখে, table-এ থাকা 100,000 chunk নয়। তাই একটি hosted reranking API প্রতি প্রশ্নের জন্য 40টি ছোট জোড়ার ওপর চার্জ করে এবং ব্যয়বহুল পর্যায়ে যাওয়ার আগেই সবচেয়ে বাজে false positive-গুলোকে বাদ দেয়।
Generation হলো পুনরাবৃত্তিমূলক খরচ, এবং দুটি বিষয় একে প্রভাবিত করে। কম chunk পাঠান; recall at 10 ব্যবহার করে দেখুন উত্তরের মান না কমিয়ে কত কম chunk পাঠানো সম্ভব। prompt-এর শুরুর অংশটি byte-এর দিক থেকে স্থিতিশীল রাখুন যাতে provider-এর 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 প্রয়োজন?
কয়েক লক্ষ chunk-এর কর্পাসের জন্য একটি VPS-ই যথেষ্ট। 768 ডাইমেনশনে, 100,000 chunk-এর জন্য 294 MiB ভেক্টর ডেটা, সাথে টেক্সট এবং HNSW ইনডেক্স প্রয়োজন হয়, যা সাধারণ প্ল্যানের RAM-এই ধরে যায়। এখানে সীমাবদ্ধতা হলো মেমরি, রো (row) সংখ্যা নয়; কারণ HNSW সার্চ ইনডেক্সের বিভিন্ন জায়গায় জাম্প করে, তাই ইনডেক্স RAM-এর বাইরে চলে গেলে ল্যাটেন্সি বেড়ে যায়। ইনডেক্সের উপর pg_relation_size-এর সাথে free -m তুলনা করুন, তাহলেই বুঝতে পারবেন আপনার বর্তমান অবস্থা কী।
আমার ডকুমেন্ট এমবেড করার জন্য কি GPU প্রয়োজন?
যদি আপনি একবার এমবেড করে পরে কোয়েরি করেন, তবে প্রয়োজন নেই। 137 মিলিয়ন প্যারামিটারের মডেল যেমন nomic-embed-text CPU-তেই চলে, এবং বড় কর্পাসের উপর পুরো পাসটি সম্পন্ন করতে কয়েক ঘণ্টা সময় লাগে যা আপনি রাতে চালিয়ে রাখতে পারেন। GPU তখনই গুরুত্বপূর্ণ হয়ে ওঠে যখন ডকুমেন্টগুলো ক্রমাগত আসতে থাকে, অথবা আপনি একই বক্সে জেনারেশন চালাতে চান। আপনার সার্ভারে /api/embed-এর বিপরীতে একটি ব্যাচের সময় পরিমাপ করুন এবং আপনার মোট chunk সংখ্যা দিয়ে গুণ করুন, কারণ 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 নেই, কারণ একটি অ্যাপ্রক্সিমেট ইনডেক্স শুধুমাত্র ordered nearest neighbour কোয়েরিগুলোকেই সাপোর্ট করে।
আমার রিট্রিভাল (retrieval) ভালো কি না তা কীভাবে বুঝব?
30টি প্রশ্নের একটি গোল্ড সেট তৈরি করুন, যেখানে প্রতিটি প্রশ্নের সাথে উত্তর প্রদানকারী chunk-এর id যুক্ত থাকবে এবং তাদের সাথে প্রশ্নের এমবেডিংগুলো সংরক্ষণ করুন। এরপর recall at 10 পরিমাপ করুন, যা নির্দেশ করে কতবার সঠিক chunk-টি প্রথম 10টির মধ্যে এসেছে, এবং MRR পরিমাপ করুন, যা সেটিকে প্রথম র্যাঙ্কে রাখার জন্য রিওয়ার্ড দেয়। এই দুটি সংখ্যাই আপনাকে জানাবে যে chunk-এর আকার, এমবেডিং মডেল বা rank fusion-এ কোনো পরিবর্তন কার্যকর হয়েছে কি না। এগুলো ছাড়া আপনি কেবল সেটিংস পরিবর্তন করবেন এবং গুটিকয়েক উত্তরের ওপর ভিত্তি করে আপনার ধারণার ওপর নির্ভর করবেন।