अपने VPS पर RAG pipeline कैसे बनाएं: पूरी गाइड
अपने VPS पर RAG pipeline बनाने का तरीका जानें। इसमें pgvector schema, HNSW इंडेक्सिंग, स्थानीय embedding model और retrieval टेस्ट के लिए आवश्यक SQL क्वेरी शामिल हैं।
एक self-hosted RAG pipeline कैसी दिखती है
एक RAG (retrieval augmented generation) pipeline के पाँच चरण होते हैं: दस्तावेजों को chunk करना, chunks को embed करना, vectors को store करना, किसी प्रश्न के लिए सबसे करीबी chunks को retrieve करना, और उन chunks को एक language model के पास भेजना जो उत्तर लिखता है। जिस VPS को आप पहले से किराए पर ले रहे हैं, उस पर पहले चार चरण चलते हैं। pgvector extension के साथ PostgreSQL vectors को रखता है, और Ollama द्वारा संचालित एक छोटा embedding model text को vectors में बदल देता है। केवल अंतिम चरण को ही सर्वर से बाहर जाने की आवश्यकता होती है।
यह विभाजन ही इस गाइड का मुख्य तर्क है। Chunking पूरी तरह से CPU का काम है। Embedding एक 137 मिलियन parameter वाला model है जो कुछ सौ megabytes RAM में आ जाता है। Storage एक Postgres table है जिसका आकार आप एक भी row लिखने से पहले गणित लगाकर निकाल सकते हैं। लाखों chunks के corpus के लिए, यह सब एक साधारण VPS पर चल सकता है। Generation अलग है, क्योंकि इसमें हर प्रश्न पर हमेशा कुछ न कुछ खर्च होता है।
RAG pipeline के किन हिस्सों में वास्तव में खर्च होता है
DigitalOcean का end to end RAG tutorial एक managed vector database और एक hosted embedding model का उपयोग करता है, और इसका लागत वाला भाग गुणात्मक (qualitative) है: बार-बार आने वाली queries को cache करें, retrieve किए गए chunks की संख्या कम रखें, और generation से पहले rerank करें। यह सलाह सही है। यह उस विकल्प को छोड़ देता है जो गणित को बदल देता है, और वह है embedding model को उसी सर्वर पर चलाना जिसके लिए आप पहले से भुगतान कर रहे हैं।
डॉलर के बजाय tokens की गणना करें, क्योंकि जब मूल्य सूची बदलती है तो token की संख्या पुरानी नहीं होती। 400 tokens वाले 100,000 chunks का एक corpus लें, उससे पूछे गए 10,000 सवाल, प्रति उत्तर model को भेजे गए 8 chunks, 100 token का सवाल और instruction block, और 400 token के उत्तर।
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"
}
]पूरे corpus को embed करना 40 मिलियन tokens है, और यह एक बार होता है। उन 10,000 सवालों में विभाजित करने पर यह प्रति सवाल 4,000 tokens होता है। एक लाख सवाल पूछने पर यह 400 तक गिर जाता है। Generation कभी कम नहीं होता। यह आपके द्वारा उत्तर दिए जाने वाले हर सवाल पर 3,300 tokens in और 400 tokens out का खर्च आता है।
इसलिए पैसा उस चरण के पीछे जाता है जो दोहराया जाता है। Embedding चरण को स्वयं संभालें (own करें), क्योंकि आप इसके लिए एक बार भुगतान करते हैं और VPS वैसे भी चल रहा होता है। Generation चरण को खरीदें, क्योंकि वहीं एक बेहतर model वास्तविक पैसे के लायक होता है। Caching भी इसी कारण से मायने रखती है: एक cache hit उस एकमात्र चरण को छोड़ देता है जिसकी लागत कभी कम (amortise) नहीं होती। KV cache और prompt cache के बीच का अंतर यह तय करता है कि आप उस आधे हिस्से में से किसे reuse कर सकते हैं, और एक RAG prompt में एक स्थिर instruction block होता है जिसके बाद एक बदलता हुआ chunk block होता है, जो कि वह आकार है जिसे सबसे अधिक लाभ मिलता है।
Chunking: fixed size और overlap डिफ़ॉल्ट रूप से सही क्यों हैं
Chunk वह इकाई है जिसे आप retrieve करते हैं, इसलिए इसका आकार downstream की हर चीज़ को निर्धारित करता है। इसे इतना छोटा होना चाहिए कि इसका embedding केवल एक विषय के बारे में हो, क्योंकि embedding space में एक एकल बिंदु होता है। यदि कोई chunk चार विषयों को कवर करता है, तो वह उनके बीच में स्थित होगा और किसी के भी करीब नहीं होगा। इसे इतना बड़ा होना चाहिए कि यह अपने आप में किसी प्रश्न का उत्तर दे सके, क्योंकि language model केवल chunk को देखता है, उसके आसपास के पूरे document को नहीं।
300 शब्दों से शुरुआत करें और 50 शब्दों का overlap रखें। English में लगभग 1.3 tokens प्रति शब्द होते हैं, इसलिए 300 शब्द लगभग 400 tokens के बराबर हैं। Overlap इसलिए आवश्यक है क्योंकि boundary पर आने वाला वाक्य अन्यथा दो हिस्सों में बंट जाएगा, और कोई भी हिस्सा प्रश्न का उत्तर नहीं दे पाएगा।
जहाँ documents में structure हो, वहां पहले structure के आधार पर split करें। Headings पर break करें, फिर paragraphs पर, और fixed size के नियम को केवल उसी section के अंदर लागू करें जो अभी भी बहुत लंबा है। यदि कोई chunk वाक्य के बीच से शुरू होता है, तो वह अंतिम उत्तर में पढ़ने में खराब लगता है, क्योंकि model वही quote करता है जो आपने उसे दिया है।
Chunking को मापने से पहले उसे tune न करें। Fixed size और overlap deterministic होते हैं और इन्हें दोबारा चलाना सस्ता है, जो इसे एक ऐसा baseline बनाता है जिसे आप बेहतर बना सकते हैं। पहले scoring query को तैयार करें, फिर एक बार में केवल एक चीज़ बदलें।
एक ही बॉक्स पर एम्बेडिंग, और RAM तथा लेटेंसी पर इसका प्रभाव
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 कॉन्टेक्स्ट का विज्ञापन करता है। उस सीमा से लंबा कोई भी चंक वहीं काट दिया जाता है और फिर भी एम्बेड हो जाता है, इसलिए उसका पिछला हिस्सा सर्च नहीं किया जा सकता। टेस्टिंग के दौरान "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 राउंड ट्रिप और मॉडल लुकअप 32 बार के बजाय केवल एक बार होता है। 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 लोड किए गए मॉडल का रेजिडेंट साइज प्रिंट करता है, जो वह RAM है जिसे आपने तब तक के लिए कमिट किया है जब तक keep_alive उसे होल्ड करके रखता है। time आउटपुट को बैच साइज से विभाजित करने पर आपको प्रति चंक सेकंड मिलते हैं। एक बार की इंडेक्सिंग लागत प्राप्त करने के लिए इसे चंक काउंट से गुणा करें। केवल CPU वाले प्लान पर, 100,000 चंक के कॉर्पस में मिनटों के बजाय घंटों का समय लगने की उम्मीद रखें। यह ठीक है, क्योंकि यह एक बार होता है और इसे nice -n 19 के तहत रात भर चलाया जा सकता है। यदि घंटों का समय लगना ठीक नहीं है, तो असली सवाल यह है कि क्या GPU किराए पर लेना फायदेमंद है, और यह API टोकन के मुकाबले ब्रेक-ईवन कैलकुलेशन का विषय है, न कि पसंद का।
यदि बॉक्स पहले से ही एक चैट मॉडल सर्व कर रहा है, तो एम्बेडिंग मॉडल दूसरा रेजिडेंट मॉडल होगा और RAM की खपत बढ़ जाएगी। Ollama को VPS पर चलाना जनरेशन साइड की साइजिंग को कवर करता है, और एक सेल्फ-होस्टेड मॉडल समवर्ती उपयोगकर्ताओं के तहत क्या करता है यह बताता है कि जब कई लोग एक साथ पूछते हैं तो क्या होता है। एम्बेडिंग मॉडल इतना छोटा है कि वह किसी के भी साथ रह सकता है।
इंडेक्सिंग स्क्रिप्ट, शुरू से अंत तक
Ubuntu 24.04 पर वर्चुअल एनवायरनमेंट के बाहर एक साधारण pip install, error: externally-managed-environment के साथ रुक जाता है, क्योंकि सिस्टम Python, 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 चंक्स 294 MiB वेक्टर डेटा लेते हैं। 1536 डाइमेंशन वाले होस्टेड मॉडल द्वारा एम्बेड किए गए उसी कॉर्पस को 587 MiB की आवश्यकता होती है, और उस पर बना इंडेक्स उसी अनुपात में बढ़ता है। हाफ प्रिसिजन दोनों को आधा कर देता है: halfvec(768) उस कॉर्पस को 147 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 पहले से इनेबल्ड है, और इस गाइड की हर क्वेरी वहाँ बिना किसी बदलाव के काम करती है।
Indexing: 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 vectors देता है, तब तक <=> ऑपरेटर के साथ 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;एक उच्च मान ग्राफ के अधिक हिस्से को सर्च करता है, बेहतर neighbours ढूंढता है और इसमें latency बढ़ती है। यह एक सेशन सेटिंग है, इसलिए आप इंडेक्स को छुए बिना किसी एक क्वेरी के लिए इसे बढ़ा सकते हैं।
यदि कोई क्वेरी इंडेक्स का उपयोग नहीं कर रही है, तो प्लान में यह दिखाई देता है।
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) प्राप्त होता है जिनमें वह दिखाई देता है, और दोनों स्कोर जुड़ जाते हैं। इसमें स्कोर नॉर्मलाइजेशन की आवश्यकता नहीं होती, क्योंकि यह दूरियों के बजाय स्थितियों (positions) को पढ़ता है।
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 फ़िल्टर जोड़ने से आपके द्वारा मांगे गए परिणामों से कम पंक्तियाँ मिल सकती हैं, क्योंकि इंडेक्स को पहले खोजा जाता है और फ़िल्टर बाद में चलता है। SET hnsw.iterative_scan = relaxed_order; pgvector को तब तक स्कैन जारी रखने के लिए कहता है जब तक कि पर्याप्त पंक्तियाँ न मिल जाएं।
आप यह कैसे पता लगाएँगे कि retrieval प्रभावी है या नहीं?
यह वह चरण है जिसे लगभग हर RAG गाइड छोड़ देती है, और यही एकमात्र तरीका है जिससे आप जान सकते हैं कि आपके द्वारा चुने गए अन्य विकल्प मददगार साबित हुए या नहीं। इसके लिए किसी evaluation framework की आवश्यकता नहीं है। आपको केवल 30 प्रश्नों और प्रत्येक प्रश्न का उत्तर देने वाले chunk के id की आवश्यकता है।
इन्हें स्वयं लिखें। उन प्रश्नों को लें जो लोग वास्तव में इस corpus के बारे में पूछते हैं, प्रत्येक को run करें, जो परिणाम वापस आए उन्हें पढ़ें, और उस chunk का id रिकॉर्ड करें जिसे आना चाहिए था। 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: prefix के साथ embed करें, उसे store करें, और फिर पूरे सेट को एक ही query में score करें।
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 प्राप्त होता है: यानी वह उत्तर कितनी बार उस window के भीतर था जिसे आप model को भेजते हैं। MRR (mean reciprocal rank) सही chunk की स्थिति से 1 को विभाजित करके उसका औसत निकालता है और miss होने पर शून्य गिनता है, इसलिए यह उत्तर को आठवें स्थान के बजाय पहले स्थान पर रखने के लिए बेहतर reward देता है। जब आप chunk size बदलते हैं, embedding model बदलते हैं या keyword search जोड़ते हैं, तो ये दोनों संख्याएँ बदलती हैं, और अब आप देख सकते हैं कि वे किस दिशा में बदली हैं।
recall at 10 को बाकी सब चीजों से ऊपर प्राथमिकता दें, क्योंकि generator उस chunk का उपयोग नहीं कर सकता जो उसे कभी मिला ही नहीं। जब recall at 10 का मान 0.9 हो और उत्तर फिर भी गलत हों, तो गलती prompt या model में है, retrieval में नहीं। यह एक विभाजन (split) आपको अनुमान लगाने में लगने वाले कई दिनों के समय को बचा सकता है।
index की अलग से जाँच करें। Approximate search से recall कम होता है, और pgvector आपको दिखाता है कि कितना: exact search के साथ वही query run करें और ids की तुलना करें।
BEGIN;
SET LOCAL enable_indexscan = off; -- use exact search
SELECT id FROM chunks ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;दस में से नौ ids का समान होना यह दर्शाता है कि ef_search ठीक है। यदि दस में से चार ही समान हैं, तो इसे बढ़ाएँ।
Reranking और generation: जहाँ API पैसे कमाता है
Reranker एक अलग प्रकार का model है। यह प्रश्न और एक chunk को एक साथ पढ़ता है और उस जोड़े (pair) को score देता है। यह स्वतंत्र रूप से compute किए गए दो embeddings की तुलना करने से बेहतर है, लेकिन पूरे corpus पर इसे चलाना बहुत धीमा होता है। इसीलिए यह यहाँ उपयोगी है। यह retrieval द्वारा लौटाए गए 40 candidates को देखता है, न कि table में मौजूद 100,000 chunks को। इसलिए, एक hosted reranking API प्रति प्रश्न 40 छोटे जोड़ों के लिए शुल्क लेता है और सबसे खराब false positives को महंगे चरण तक पहुँचने से पहले ही हटा देता है।
Generation एक आवर्ती खर्च (recurring bill) है, और इसे दो तरीकों से नियंत्रित किया जा सकता है। कम chunks भेजें; यह पता लगाने के लिए recall at 10 का उपयोग करें कि उत्तर खोए बिना आप कम से कम कितने chunks भेज सकते हैं। Prompt के शुरुआती हिस्से को byte-by-byte स्थिर रखें ताकि provider का prompt cache उसे hit कर सके, और retrieved chunks को उस स्थिर हिस्से के बाद रखें। प्रश्नों के आधार पर तैयार उत्तरों को भी cache करें, क्योंकि सबसे सस्ता generated token वह है जिसे आपने पिछले सप्ताह generate किया था।
सर्वर का आकार तय करना और यह कब अपर्याप्त हो जाता है
यहाँ दिए गए आकार निर्धारण के हर नियम को आप अनुमान लगाने के बजाय मापते हैं।
- RAM सबसे महत्वपूर्ण बाधा है:
ollama psसे प्राप्त resident model size, साथ में HNSW index का आकार, औरshared_buffers, जिसमें connections और page cache के लिए अतिरिक्त जगह (headroom) छोड़ी गई हो। - Disk के लिए
pg_total_relation_size('chunks')का दोगुना स्थान चाहिए, क्योंकि index को फिर से बनाने (rebuild) के दौरान दोनों प्रतियाँ एक साथ मौजूद रहती हैं। - CPU reindex के समय को निर्धारित करता है, जिसे आप प्रति chunk मापे गए seconds को chunk की कुल संख्या से गुणा करके निकाल सकते हैं।
- Reindexing आपकी अपेक्षा से अधिक बार होती है, क्योंकि embedding model को बदलने से पहले से संग्रहीत हर vector अमान्य (invalidate) हो जाता है।
यह डिज़ाइन उस बिंदु पर अपर्याप्त हो जाता है जिसे आप पहले से देख सकते हैं। जब HNSW index आपके द्वारा खरीदी जा सकने वाली RAM में फिट नहीं होता, तो query latency डिस्क seeks में बदल जाती है और कोई भी setting इसे ठीक नहीं कर सकती। जब एक ही table कई tenants को सेवा देती है और हर query tenant के आधार पर filter करती है, तो table को partition करना ही एकमात्र समाधान है, और यह काफी मेहनत वाला काम है। जब indexing writes और user queries एक ही सर्वर के संसाधनों के लिए संघर्ष करें, तो database को स्थानांतरित करने से पहले embedding worker को दूसरे सर्वर पर ले जाएँ। जब तक इनमें से कोई स्थिति उत्पन्न नहीं होती, तब तक आपके द्वारा किराए पर लिए गए VPS पर Postgres और pgvector एक उत्पादन-स्तर (production) का समाधान है, और ऊपर दिए गए आंकड़े आपको बताते हैं कि सीमा कितनी दूर है।
FAQ
क्या मैं एक VPS पर RAG pipeline चला सकता हूँ, या मुझे vector database की आवश्यकता है?
लाखों chunks के corpora के लिए एक VPS पर्याप्त है। 768 dimensions पर, 100,000 chunks का मतलब है 294 MiB vector डेटा, साथ ही text और HNSW index, जो एक सामान्य plan की RAM में आसानी से आ जाता है। सीमा row count के बजाय memory की है, क्योंकि HNSW search index में इधर-उधर जंप करता है, इसलिए index के RAM में न समाने पर latency बढ़ जाती है। index पर pg_relation_size की तुलना free -m से करें और आपको अपनी स्थिति का पता चल जाएगा।
क्या मुझे अपने documents को embed करने के लिए GPU की आवश्यकता है?
यदि आप एक बार embed करते हैं और बाद में query करते हैं, तो नहीं। 137 मिलियन parameter वाला model जैसे कि nomic-embed-text CPU पर चलता है, और एक बड़े corpus पर पूरा pass करने में कुछ घंटे लगते हैं जिसे आप रात भर में पूरा कर सकते हैं। GPU तब मायने रखता है जब documents लगातार आते रहें, या जब आप उसी box पर generation चलाना चाहें। अपने सर्वर पर /api/embed के मुकाबले एक batch का समय मापें और उसे अपने chunk count से गुणा करें, क्योंकि vCPU की संख्या में इतना अंतर होता है कि कोई भी प्रकाशित आंकड़ा सटीक नहीं बैठता।
मेरी vector query HNSW index के बजाय sequential scan का उपयोग क्यों करती है?
EXPLAIN (ANALYZE, BUFFERS) के साथ plan को पढ़ें। इसका सामान्य कारण storage है: pgvector नोट करता है कि planner अपने cost estimates में out-of-line storage की गणना नहीं करता है, जिससे serial scan अपनी वास्तविक लागत से सस्ता दिखता है, और 768 dimension वाला vector 3,080 bytes का होता है, इसलिए यह डिफ़ॉल्ट रूप से TOAST table में रहता है। ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; नई rows को inline रखता है। अन्य दो कारण हैं: एक ऐसा operator जो index से मेल नहीं खाता, क्योंकि vector_cosine_ops के साथ बनाया गया index केवल <=> द्वारा उपयोग किया जाता है, और बिना ORDER BY ... LIMIT वाली query, क्योंकि एक approximate index केवल ordered nearest neighbour queries के लिए काम करता है।
मुझे कैसे पता चलेगा कि मेरा retrieval अच्छा है या नहीं?
30 प्रश्नों का एक gold set बनाएं, प्रत्येक को उस chunk के id के साथ जोड़ें जो उसका उत्तर देता है, और प्रश्न embeddings को उनके साथ store करें। फिर recall at 10 को मापें, जो यह बताता है कि सही chunk कितनी बार शीर्ष 10 में आता है, और MRR, जो उसे पहले स्थान पर रखने के लिए reward देता है। ये दो संख्याएं ही आपको बताती हैं कि chunk size, embedding model या rank fusion में किया गया बदलाव प्रभावी है या नहीं। इनके बिना आप केवल settings बदल रहे होंगे और कुछ उत्तरों के आधार पर अपने अनुमान पर भरोसा कर रहे होंगे।