SSD Nodes Learn 🎉 VPS $5.50/महिन्यापासून
मार्गदर्शक Matt Connorद्वारे Matt Connor

स्वतःच्या VPS वर RAG पाइपलाइन कशी तयार करावी

तुमच्या VPS वर RAG पाइपलाइन सेटअप करा. यामध्ये pgvector स्कीमा, HNSW इंडेक्सिंग, स्थानिक एम्बेडिंग मॉडेल्स आणि डेटा रिट्रीव्हलसाठी आवश्यक SQL क्वेरीजची सविस्तर माहिती दिली आहे.

सेल्फ-होस्टेड RAG पाइपलाइन कशी दिसते

RAG (retrieval augmented generation) पाइपलाइनमध्ये पाच टप्पे असतात: दस्तऐवजांचे तुकडे (chunk) करणे, त्या तुकड्यांचे एम्बेडिंग तयार करणे, वेक्टर स्टोअरमध्ये साठवणे, प्रश्नाशी संबंधित सर्वात जवळचे तुकडे शोधणे आणि ते तुकडे लँग्वेज मॉडेलकडे पाठवणे जे उत्तर तयार करते. तुम्ही भाड्याने घेतलेल्या VPS वर, पहिले चार टप्पे स्थानिक पातळीवर चालतात. PostgreSQL आणि त्यातील pgvector एक्स्टेंशन वेक्टर साठवतात, तर Ollama द्वारे चालणारे एक छोटे एम्बेडिंग मॉडेल मजकुराचे रूपांतर वेक्टर्समध्ये करते. फक्त शेवटचा टप्पा सर्व्हरच्या बाहेर जातो.

ही विभागणीच या मार्गदर्शिकेचा मुख्य आधार आहे. चंकिंग (chunking) हे साधे CPU चे काम आहे. एम्बेडिंगसाठी 137 दशलक्ष पॅरामीटर्सचे मॉडेल वापरले जाते, जे काहीशे MB RAM मध्ये मावते. स्टोरेजसाठी एक Postgres टेबल वापरले जाते, ज्याचा आकार तुम्ही डेटा लिहिण्यापूर्वीच गणिताने ठरवू शकता. लाखो तुकड्यांच्या कॉर्पससाठी, हे सर्व एका सामान्य VPS वर चालू शकते. जनरेशन (generation) वेगळे आहे, कारण प्रत्येक प्रश्नासाठी त्याचा खर्च कायमस्वरूपी होत राहतो.

RAG पाइपलाइनचे कोणते भाग प्रत्यक्ष खर्च वाढवतात

DigitalOcean चे end to end RAG ट्युटोरियल एका मॅनेज्ड vector database आणि होस्ट केलेल्या embedding model चा वापर सुचवते. त्यातील खर्चाचा विभाग केवळ गुणात्मक माहिती देतो: वारंवार विचारले जाणारे प्रश्न कॅश करा, रिट्रिव्ह केलेल्या chunks ची संख्या कमी ठेवा आणि जनरेशनपूर्वी rerank करा. हा सल्ला योग्य आहे. मात्र, यात एक पर्याय वगळला आहे जो संपूर्ण गणित बदलू शकतो, तो म्हणजे तुमच्याकडे आधीच असलेल्या सर्व्हरवर embedding model चालवणे.

डॉलरऐवजी टोकन्सची गणना करा, कारण किमती बदलल्या तरी टोकनची संख्या बदलत नाही. समजा तुमच्याकडे 400 टोकन्सचे 100,000 chunks असलेले एक corpus आहे, त्यावर 10,000 प्रश्न विचारले जातात, प्रत्येक उत्तरासाठी 8 chunks मॉडेलला पाठवले जातात, 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"
  }
]

संपूर्ण corpus एम्बेड करण्यासाठी 40 दशलक्ष टोकन्स लागतात आणि ही प्रक्रिया एकदाच होते. 10,000 प्रश्नांमध्ये विभागल्यास हे प्रमाण प्रति प्रश्न 4,000 टोकन्स इतके होते. जर तुम्ही एक लाख प्रश्न विचारले, तर हे प्रमाण 400 पर्यंत खाली येते. जनरेशनचा खर्च मात्र कधीच कमी होत नाही. प्रत्येक प्रश्नासाठी 3,300 टोकन्स इनपुट आणि 400 टोकन्स आउटपुट असा खर्च कायम राहतो.

त्यामुळे, जो टप्पा वारंवार येतो तिथेच खरा खर्च होतो. एम्बेडिंगची पायरी स्वतःच्या नियंत्रणात ठेवा, कारण त्यासाठी एकदाच खर्च करावा लागतो आणि तुमचा VPS आधीच सुरू असतो. जनरेशनची पायरी विकत घ्या, कारण तिथे अधिक चांगल्या मॉडेलसाठी पैसे मोजणे सार्थ ठरते. कॅशिंग याच कारणासाठी महत्त्वाचे आहे: कॅश हिटमुळे अशा टप्प्याला वगळता येते ज्याचा खर्च कधीही कमी होत नाही. KV cache आणि prompt cache मधील फरक यातील कोणता भाग तुम्ही पुन्हा वापरू शकता हे ठरवतो. RAG प्रॉम्प्टमध्ये स्थिर सूचनांचा संच आणि त्यानंतर बदलणारा chunk ब्लॉक असतो, ज्याला या रचनेचा सर्वाधिक फायदा होतो.

Chunking: फिक्स्ड साईज आणि ओव्हरलॅप हा योग्य डिफॉल्ट पर्याय का आहे

Chunk हे तुम्ही रिट्रीव्ह (retrieve) करत असलेले युनिट आहे, त्यामुळे त्याची साईज पुढील सर्व प्रक्रिया ठरवते. ती इतकी लहान असावी की त्याचे एम्बेडिंग (embedding) एकाच विषयावर केंद्रित असेल, कारण एम्बेडिंग हे अवकाशातील एकच बिंदू असते. जर एखादा chunk चार विषयांना कव्हर करत असेल, तर तो त्या सर्वांच्या मध्ये येतो आणि कोणत्याही विषयाच्या जवळ नसतो. तो इतका मोठा असावा की तो स्वतःहून प्रश्नाचे उत्तर देऊ शकेल, कारण लँग्वेज मॉडेलला फक्त तो chunk दिसतो, त्याभोवतीचा संपूर्ण डॉक्युमेंट नाही.

300 शब्दांपासून सुरुवात करा आणि 50 शब्दांचा ओव्हरलॅप ठेवा. इंग्रजीमध्ये साधारणपणे प्रति शब्द 1.3 टोकन्स असतात, त्यामुळे 300 शब्द म्हणजे सुमारे 400 टोकन्स होतात. ओव्हरलॅप असणे आवश्यक आहे कारण सीमेवर येणारे वाक्य अन्यथा अर्धे कापले जाते आणि दोन्हीपैकी कोणताही भाग प्रश्नाचे उत्तर देऊ शकत नाही.

जिथे डॉक्युमेंट्समध्ये रचना (structure) आहे, तिथे आधी स्ट्रक्चरनुसार स्प्लिट करा. हेडिंग्सवर ब्रेक करा, त्यानंतर पॅराग्राफवर करा आणि फिक्स्ड साईजचा नियम फक्त अशा सेक्शनमध्ये लावा जो अजूनही खूप मोठा आहे. वाक्याच्या मध्यभागी सुरू होणारा chunk अंतिम उत्तरात वाचायला खराब वाटतो, कारण मॉडेल तुम्ही त्याला दिलेला मजकूरच पुन्हा कोट (quote) करते.

जोपर्यंत तुम्ही मोजमाप (measure) करू शकत नाही, तोपर्यंत chunking मध्ये बदल करू नका. फिक्स्ड साईज विथ ओव्हरलॅप हे डिटरमिनिस्टिक (deterministic) आहे आणि पुन्हा रन करण्यासाठी स्वस्त आहे, ज्यामुळे तो एक असा बेसलाईन ठरतो ज्याला तुम्ही मागे टाकू शकता. आधी स्कोअरिंग क्वेरी तयार करा आणि त्यानंतर एका वेळी एकच गोष्ट बदला.

एकाच सर्व्हरवर एम्बेडिंग आणि त्याचा RAM व लेटन्सीवरील परिणाम

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: ने प्रीफिक्स करणे आवश्यक आहे. हे न वापरल्यास कोणतीही एरर येत नाही: तुम्हाला वेक्टर्स मिळतात, परंतु रिट्रीव्हलची गुणवत्ता खालावते आणि याचे कारण सांगणारी कोणतीही लॉग लाईन तयार होत नाही.

मोठे इनपुट शांतपणे ट्रंकेट केले जाते. /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/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 हे लोड केलेल्या मॉडेलची रेसिडेंट साईज दर्शवते, जी RAM तुम्ही keep_alive जोपर्यंत मॉडेल मेमरीमध्ये ठेवते तोपर्यंत वापरता. time आउटपुटला बॅच साईजने भागल्यास तुम्हाला प्रति चंक लागणारा वेळ (सेकंदात) मिळेल. एकूण इंडेक्सिंग खर्च काढण्यासाठी याला चंक संख्येने गुणा. फक्त CPU असलेल्या प्लॅनवर, 1,00,000 चंक्सच्या कॉर्पससाठी मिनिटांऐवजी तास लागू शकतात. हे ठीक आहे, कारण ही प्रक्रिया एकदाच करायची असते आणि ती nice -n 19 वापरून रात्रीच्या वेळी चालवता येते. जर तासनतास लागणे परवडणारे नसेल, तर GPU भाड्याने घेणे फायदेशीर ठरेल का, हे ठरवण्यासाठी API टोकन्सच्या तुलनेत ब्रेक-इव्हन कॅल्क्युलेशन करणे हाच खरा पर्याय आहे.

जर सर्व्हरवर आधीच चॅट मॉडेल चालू असेल, तर एम्बेडिंग मॉडेल हे दुसरे रेसिडेंट मॉडेल असेल आणि RAM चा वापर वाढेल. Ollama VPS वर चालवणे यामध्ये जनरेशन साईजचे नियोजन कसे करावे हे दिले आहे आणि सेल्फ-होस्टेड मॉडेल एकाच वेळी अनेक वापरकर्ते असताना कसे काम करते यामध्ये एकाच वेळी अनेक वापरकर्ते असल्यास काय होते हे स्पष्ट केले आहे. एम्बेडिंग मॉडेल इतके लहान आहे की ते कोणत्याही मॉडेलसोबत सहज चालू शकते.

इंडेक्सिंग स्क्रिप्ट, सुरुवातीपासून शेवटपर्यंत

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() हे तुमचे आहे: जे तुमच्या फाईल्स किंवा रो (rows) स्कॅन करते आणि डॉक्युमेंट आयडी व मजकूर प्रदान करते. बाकी सर्व प्रक्रिया पाइपलाईनचा भाग आहे.

स्टोरेज: 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

पॅकेजच्या नावातील क्रमांक तुमच्या सर्व्हरच्या मुख्य आवृत्तीशी (major version) जुळला पाहिजे. त्यानंतर रोल, डेटाबेस आणि एक्स्टेंशन तयार करा.

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 बाइट्सचा असतो, त्यामुळे 100,000 चंक्समध्ये 294 MiB वेक्टर डेटा साठवला जातो. 1536 डायमेन्शनच्या होस्टेड मॉडेलद्वारे एम्बेड केलेले तेच कॉर्पस 587 MiB जागा घेते आणि त्यावरचा इंडेक्स त्याच प्रमाणात वाढतो. हाफ प्रिसिजनमुळे दोन्ही आकार निम्मे होतात: halfvec(768) ते कॉर्पस 147 MiB मध्ये साठवते. यामुळे रिकॉलवर काही परिणाम होतो का, हे खालील स्कोरिंग क्वेरी एका रनमध्ये स्पष्ट करते.

हे आकडे फक्त वेक्टर कॉलमसाठी आहेत. मजकूर, रो ओव्हरहेड आणि इंडेक्स हे अतिरिक्त असतात, त्यामुळे प्रत्यक्ष टेबलचे मोजमाप करा.

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 आणि युजर अकाउंट्ससह हवे असेल, तर self-hosted Supabase stack हा एक पर्याय आहे. यामध्ये Postgres आणि pgvector आधीच इनेबल केलेले असतात आणि या मार्गदर्शिकेतील प्रत्येक क्वेरी तिथे कोणतीही तडजोड न करता काम करते.

Indexing: महत्त्वाचे HNSW सेटिंग्स

काही हजार ओळींपर्यंत इंडेक्स वापरू नका. Exact search प्रत्येक ओळ वाचते, या आकारासाठी ते पुरेसे वेगवान असते आणि त्याचे रिकॉल (recall) अचूक असते. जेव्हा sequential scan पुरेसा वेगवान राहत नाही, तेव्हा इंडेक्स जोडा. यातला ट्रेड-ऑफ लक्षात घ्या: approximate index अंदाजे योग्य शेजारी (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 वापरा. कारण 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.

ही त्रुटी (error) नाही आणि इंडेक्स बनवण्याची प्रक्रिया पूर्ण होते, परंतु ती खूप संथ मार्गाने पूर्ण होते. इंडेक्स बनवणाऱ्या सेशनमध्ये maintenance_work_mem वाढवा आणि सर्व्हरचे डिफॉल्ट सेटिंग बदलू नका. हे सेटिंग प्रति मेंटेनन्स ऑपरेशन असते आणि जागतिक स्तरावर (globally) उच्च मूल्य ठेवल्यास सर्व्हरची मेमरी संपू शकते. दुसऱ्या सेशनमधून इंडेक्स बनवण्याची प्रगती तपासा.

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 सर्च ग्राफवरून प्रवास करतो, त्यामुळे तो इंडेक्समधील विखुरलेली पेजेस वाचतो, एका रेंजमधील डेटा वाचण्याऐवजी. जो इंडेक्स मेमरीमध्ये मावत नाही, तो प्रत्येक क्वेरीला डिस्क रीडमध्ये रूपांतरित करतो आणि वापरकर्त्यांना हा संथपणा जाणवतो. सर्व्हरच्या आकारासाठी हा एकच नियम आहे: इंडेक्स आणि तुम्ही सर्व्ह करत असलेल्या ओळी (rows) 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;

उच्च मूल्य अधिक ग्राफ शोधते, चांगले शेजारी शोधते आणि लॅटन्सी (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 फिल्टर लावल्यास तुम्हाला हव्या असलेल्या संख्येपेक्षा कमी ओळी (rows) मिळू शकतात, कारण इंडेक्स आधी शोधला जातो आणि फिल्टर नंतर लागू होतो. SET hnsw.iterative_scan = relaxed_order; मुळे pgvector जोपर्यंत पुरेशा ओळी मिळत नाहीत तोपर्यंत स्कॅनिंग सुरू ठेवते.

रिट्रिव्हल (retrieval) किती प्रभावी आहे हे कसे ओळखावे?

बहुतेक RAG मार्गदर्शिकांमध्ये हा टप्पा वगळला जातो, परंतु इतर पर्यायांचा काही उपयोग झाला आहे की नाही हे सांगणारा हा एकमेव मार्ग आहे. यासाठी कोणत्याही इव्हॅल्युएशन फ्रेमवर्कची गरज नाही. तुम्हाला फक्त 30 प्रश्न आणि प्रत्येक प्रश्नाचे उत्तर देणाऱ्या चंकचा (chunk) id यांची आवश्यकता आहे.

हे प्रश्न स्वतः हाताने लिहा. तुमच्या कॉर्पस (corpus) बद्दल लोक जे प्रश्न विचारतात ते निवडा, प्रत्येक प्रश्न रन करा, काय उत्तर येते ते वाचा आणि ज्या चंकमध्ये योग्य उत्तर असायला हवे होते, त्याचा 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: प्रीफिक्ससह एम्बेड करा, तो स्टोअर करा आणि त्यानंतर संपूर्ण संचाचे स्कोअरिंग एकाच क्वेरीमध्ये करा.

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) मध्ये योग्य चंकच्या स्थानाने 1 ला भागले जाते आणि सरासरी काढली जाते; उत्तर न मिळाल्यास शून्य धरले जाते. त्यामुळे आठव्या क्रमांकापेक्षा पहिल्या क्रमांकावर उत्तर असण्याला यात जास्त महत्त्व दिले जाते. जेव्हा तुम्ही चंकची साईज बदलता, एम्बेडिंग मॉडेल बदलता किंवा कीवर्ड सर्च जोडता, तेव्हा हे दोन्ही आकडे बदलतात आणि आता ते कोणत्या दिशेने बदलले आहेत हे तुम्ही पाहू शकता.

'recall at 10' ला इतर कशापेक्षाही जास्त प्राधान्य द्या, कारण मॉडेलला न मिळालेला चंक ते वापरू शकत नाही. जेव्हा 'recall at 10' चा आकडा 0.9 असतो आणि तरीही उत्तरे चुकीची येतात, तेव्हा दोष प्रॉम्प्टमध्ये किंवा मॉडेलमध्ये असतो, रिट्रिव्हलमध्ये नाही. हे एक विभाजन (split) तुमचे कित्येक दिवसांचे अंदाज लावण्याचे काम वाचवते.

इंडेक्सची स्वतंत्रपणे तपासणी करा. ॲप्रोक्सिमेट सर्चमुळे (approximate search) रिकॉल कमी होतो आणि pgvector तुम्हाला हे किती प्रमाणात घडते ते दाखवते: तीच क्वेरी 'exact search' वापरून रन करा आणि id ची तुलना करा.

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

दहापैकी नऊ id समान असतील तर ef_search योग्य आहे असे समजावे. जर चारच id समान असतील, तर तो वाढवण्याची गरज आहे.

Reranking आणि generation: API जिथे पैसे कमावते

Reranker हा एक वेगळ्या प्रकारचा मॉडेल आहे. तो प्रश्न आणि एक chunk एकत्र वाचतो आणि त्या जोडीला गुण देतो. स्वतंत्रपणे मोजलेल्या दोन embeddings ची तुलना करण्यापेक्षा हे अधिक प्रभावी आहे, परंतु संपूर्ण corpus वर चालवण्यासाठी हे खूप संथ आहे. म्हणूनच ते येथे वापरले जाते. ते retrieval ने परत केलेल्या 40 उमेदवारांना पाहते, टेबलमधील 100,000 chunks ना नाही. त्यामुळे, एक hosted reranking API प्रति प्रश्न 40 लहान जोड्यांसाठी शुल्क आकारते आणि महागड्या टप्प्यापर्यंत पोहोचण्यापूर्वी सर्वात वाईट false positives काढून टाकते.

Generation हे एक आवर्ती बिल आहे आणि दोन गोष्टी त्यावर परिणाम करतात. कमी chunks पाठवा; उत्तरांची गुणवत्ता न गमावता तुम्ही किती कमी chunks पाठवू शकता हे शोधण्यासाठी recall at 10 चा वापर करा. Prompt च्या सुरुवातीचा भाग byte-for-byte स्थिर ठेवा, जेणेकरून provider चा prompt cache त्याचा वापर करू शकेल आणि retrieved chunks त्या स्थिर भागाच्या नंतर ठेवा. प्रश्नानुसार पूर्ण झालेली उत्तरे देखील cache करा, कारण सर्वात स्वस्त generated token तेच असते जे तुम्ही गेल्या आठवड्यात generate केले होते.

सर्व्हरचा आकार ठरवणे आणि तो कधी अपुरा पडतो

येथे दिलेला आकाराचा प्रत्येक नियम हा अंदाजे नसून मोजमापावर आधारित आहे.

  • RAM हा सर्वात महत्त्वाचा घटक आहे: ollama ps मधील रेसिडेंट मॉडेलची साईज, अधिक HNSW इंडेक्सची साईज, अधिक shared_buffers, आणि त्यासोबत कनेक्शन्स व पेज कॅशेसाठी शिल्लक ठेवलेली जागा.
  • डिस्कसाठी pg_total_relation_size('chunks') च्या दुप्पट जागा आवश्यक असते, कारण इंडेक्स पुन्हा तयार करताना (rebuilding) दोन्ही प्रती एकाच वेळी साठवल्या जातात.
  • CPU वर इंडेक्स पुन्हा तयार करण्याचा वेळ अवलंबून असतो; तुमच्या मोजलेल्या प्रति-चंक सेकंदांना एकूण चंक संख्येने गुणल्यास हा वेळ मिळतो.
  • इंडेक्स पुन्हा तयार करण्याची प्रक्रिया अपेक्षेपेक्षा जास्त वेळा करावी लागते, कारण एम्बेडिंग मॉडेल बदलल्यास आधी साठवलेले सर्व वेक्टर्स अवैध ठरतात.

ही रचना कधी अपुरी पडणार आहे, हे तुम्हाला आधीच लक्षात येते. जेव्हा HNSW इंडेक्स तुमच्याकडे उपलब्ध असलेल्या RAM मध्ये मावत नाही, तेव्हा क्वेरी लॅटन्सी डिस्क सीक्समध्ये रूपांतरित होते आणि कोणतीही सेटिंग ती सुधारू शकत नाही. जेव्हा एक टेबल अनेक टेनंट्सना सेवा देते आणि प्रत्येक क्वेरी टेनंटनुसार फिल्टर केली जाते, तेव्हा टेबलचे पार्टिशनिंग करणे हाच उपाय उरतो, आणि हे एक मोठे काम आहे. जेव्हा इंडेक्सिंग राइट्स आणि युझर क्वेरीज एकाच सर्व्हरवर एकमेकांशी स्पर्धा करतात, तेव्हा डेटाबेस हलवण्यापूर्वी एम्बेडिंग वर्करला दुसऱ्या सर्व्हरवर हलवा. जोपर्यंत यातील कोणतीही परिस्थिती उद्भवत नाही, तोपर्यंत तुम्ही भाड्याने घेतलेल्या VPS वर Postgres आणि pgvector वापरणे हा एक उत्पादन-क्षम (production-ready) पर्याय आहे, आणि वरील आकडे तुम्हाला मर्यादा किती दूर आहे हे सांगतात.

FAQ

मी एका VPS वर RAG पाइपलाइन चालवू शकतो का, की मला वेक्टर डेटाबेसची गरज आहे?

काही लाख चंक्स (chunks) असलेल्या कॉर्पोरासाठी एक VPS पुरेसा आहे. 768 डायमेन्शन्सवर, 1,00,000 चंक्स म्हणजे 294 MiB वेक्टर डेटा, अधिक मजकूर आणि HNSW इंडेक्स, जे सामान्य प्लॅनच्या RAM मध्ये सहज मावते. मर्यादा ही रो (row) संख्येपेक्षा मेमरीवर अवलंबून असते, कारण HNSW सर्च इंडेक्समध्ये इकडे-तिकडे उड्या मारतो. त्यामुळे इंडेक्स RAM मध्ये मावेनासा झाला की लॅटन्सी (latency) वाढते. इंडेक्सवरील pg_relation_size ची तुलना free -m सोबत करा, म्हणजे तुम्हाला तुमची स्थिती समजेल.

माझे डॉक्युमेंट्स एम्बेड करण्यासाठी मला GPU ची गरज आहे का?

जर तुम्ही एकदाच एम्बेडिंग करून नंतर क्वेरी करणार असाल, तर गरज नाही. 137 दशलक्ष पॅरामीटर्सचे nomic-embed-text सारखे मॉडेल CPU वर चालू शकते. मोठ्या कॉर्पसवर पूर्ण प्रक्रिया करण्यासाठी लागणारे काही तास तुम्ही रात्रीच्या वेळी वापरू शकता. जेव्हा डॉक्युमेंट्स सतत येत असतात किंवा तुम्हाला त्याच सर्व्हरवर जनरेशन करायचे असते, तेव्हा GPU महत्त्वाचा ठरतो. तुमच्या सर्व्हरवर /api/embed वापरून एका बॅचचा वेळ मोजा आणि त्याला तुमच्या एकूण चंक्सच्या संख्येने गुणा, कारण vCPU ची संख्या वेगवेगळ्या सर्व्हरवर खूप वेगळी असते, त्यामुळे प्रकाशित आकडेवारीवर अवलंबून राहणे चुकीचे ठरेल.

माझी वेक्टर क्वेरी HNSW इंडेक्सऐवजी सीक्वेंशियल स्कॅन का वापरते?

EXPLAIN (ANALYZE, BUFFERS) वापरून प्लॅन वाचा. याचे सामान्य कारण म्हणजे स्टोरेज: pgvector नोंदवते की प्लॅनर त्याच्या कॉस्ट एस्टिमेटमध्ये 'आउट ऑफ लाईन' स्टोरेजची गणना करत नाही, ज्यामुळे सीरिअल स्कॅन स्वस्त वाटतो. 768 डायमेन्शनचा वेक्टर 3,080 बाइट्सचा असतो, त्यामुळे तो डीफॉल्टनुसार TOAST टेबलमध्ये राहतो. ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; नवीन रो (rows) इनलाइन ठेवते. इतर दोन कारणे म्हणजे, इंडेक्सशी जुळत नसलेला ऑपरेटर (कारण vector_cosine_ops ने बनवलेला इंडेक्स फक्त <=> द्वारे वापरला जातो) आणि ORDER BY ... LIMIT नसलेली क्वेरी (कारण ॲप्रोक्सिमेट इंडेक्स फक्त ऑर्डर्ड निअरेस्ट नेबर क्वेरीसाठी वापरला जातो).

माझी रिट्रीव्हल (retrieval) प्रणाली चांगली आहे हे मला कसे समजेल?

30 प्रश्नांचा एक 'गोल्ड सेट' तयार करा. प्रत्येक प्रश्नासोबत त्याचे उत्तर देणाऱ्या चंकची ID जोडा आणि प्रश्न एम्बेडिंग्स त्यासोबत स्टोअर करा. त्यानंतर 'रिकॉल ॲट 10' (recall at 10) मोजा, म्हणजे 10 पैकी किती वेळा योग्य चंक सापडतो, आणि MRR मोजा, जे योग्य चंक पहिल्या क्रमांकावर असल्यास गुण देते. चंकची साईज, एम्बेडिंग मॉडेल किंवा रँक फ्युजनमध्ये केलेला बदल फायदेशीर आहे की नाही, हे या दोन आकड्यांवरून समजते. याशिवाय, तुम्ही फक्त सेटिंग्ज बदलत असता आणि काही मोजक्या उत्तरांच्या आधारे अंदाज बांधत असता.