SSD Nodes Learn 🎉 VPS از $5.50/ماه
راهنماها Matt Connorتوسط Matt Connor

پیاده‌سازی خط‌لوله RAG روی VPS شخصی

با استفاده از PostgreSQL و افزونه pgvector یک سیستم RAG کامل روی VPS بسازید. این راهنما شامل تنظیمات HNSW، مدل embedding محلی با Ollama و کدهای SQL برای اجرای بازیابی است.

ساختار یک خط‌لوله RAG در حالت self-hosted

یک خط‌لوله RAG (تولید با بازیابی تقویت‌شده) شامل پنج مرحله است: قطعه‌بندی اسناد، تبدیل قطعات به بردار (embedding)، ذخیره بردارها، بازیابی نزدیک‌ترین موارد برای یک پرسش، و ارسال آن قطعات به یک مدل زبانی برای نوشتن پاسخ. روی یک VPS که از قبل اجاره کرده‌اید، چهار مرحله اول روی همان سرور اجرا می‌شوند. PostgreSQL به همراه افزونه pgvector بردارها را نگه می‌دارد و یک مدل embedding کوچک که توسط Ollama سرویس‌دهی می‌شود، متن را به بردار تبدیل می‌کند. تنها مرحله آخر است که باید از سرور خارج شود.

این تفکیک، استدلال اصلی این راهنماست. قطعه‌بندی (chunking) یک پردازش ساده CPU است. مدل embedding یک مدل با 137 میلیون پارامتر است که در چند صد مگابایت RAM جای می‌گیرد. ذخیره‌سازی نیز یک جدول Postgres است که حجم آن را می‌توانید پیش از درج حتی یک ردیف، با محاسبات ریاضی تخمین بزنید. برای مجموعه‌ای با صدها هزار قطعه، تمام این مراحل روی یک VPS معمولی اجرا می‌شوند. مرحله تولید (generation) متفاوت است، زیرا برای هر پرسش، تا ابد هزینه در بر دارد.

کدام بخش‌های یک خط لوله RAG واقعاً هزینه دارند

آموزش RAG سرتاسری در DigitalOcean از یک دیتابیس برداری مدیریت‌شده و یک مدل embedding میزبانی‌شده استفاده می‌کند و بخش هزینه‌های آن کیفی است: کش کردن پرس‌وجوهای تکراری، محدود نگه داشتن تعداد تکه‌های (chunks) بازیابی‌شده، و رتبه‌بندی مجدد (rerank) پیش از تولید پاسخ. این توصیه‌ها درست هستند. اما این آموزش گزینه‌ای را که محاسبات را تغییر می‌دهد نادیده می‌گیرد: اجرای مدل embedding روی همان سروری که همین حالا هم بابت آن هزینه پرداخت می‌کنید.

به‌جای دلار، توکن‌ها را بشمارید، زیرا تعداد توکن‌ها با تغییر لیست قیمت‌ها قدیمی نمی‌شود. یک مجموعه داده (corpus) شامل 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"
  }
]

Embedding کل مجموعه داده برابر با 40 میلیون توکن است و این کار فقط یک‌بار انجام می‌شود. اگر این مقدار را بین آن 10,000 پرسش تقسیم کنید، به 4,000 توکن برای هر پرسش می‌رسد. اگر صد هزار پرسش بپرسید، این عدد به 400 کاهش می‌یابد. اما هزینه تولید (generation) هرگز کاهش نمی‌یابد. این مرحله در هر پرسشی که پاسخ می‌دهید، 3,300 توکن ورودی و 400 توکن خروجی هزینه دارد.

بنابراین، پول در مرحله‌ای هزینه می‌شود که تکرار می‌شود. مرحله embedding را خودتان انجام دهید، زیرا فقط یک‌بار بابت آن هزینه می‌دهید و VPS شما در هر صورت روشن است. مرحله تولید را خریداری کنید، زیرا در آنجا یک مدل بهتر ارزش واقعی هزینه کردن را دارد. کش کردن نیز به همین دلیل اهمیت دارد: یک cache hit باعث می‌شود مرحله‌ای که هزینه‌اش هرگز مستهلک نمی‌شود، نادیده گرفته شود. تفاوت بین KV cache و prompt cache تعیین می‌کند که کدام نیمه از این فرآیند را می‌توانید دوباره استفاده کنید، و یک prompt در RAG دارای یک بلوک دستورالعمل ثابت است که به دنبال آن یک بلوک متغیر از تکه‌ها قرار می‌گیرد؛ این همان ساختاری است که بیشترین بهره را از کش کردن می‌برد.

تکه‌بندی: چرا اندازه ثابت با هم‌پوشانی، پیش‌فرض مناسبی است

تکه (chunk) واحدی است که بازیابی می‌کنید، بنابراین اندازه آن بر تمام مراحل بعدی تأثیر می‌گذارد. اندازه تکه باید به‌اندازه‌ای کوچک باشد که embedding آن فقط به یک موضوع واحد مربوط شود؛ زیرا embedding یک نقطه واحد در فضا است و اگر یک تکه چهار موضوع مختلف را پوشش دهد، در میان آن‌ها قرار می‌گیرد و به هیچ‌کدام نزدیک نخواهد بود. همچنین باید به‌اندازه‌ای بزرگ باشد که بتواند به‌تنهایی به یک پرسش پاسخ دهد، زیرا مدل زبانی فقط همان تکه را می‌بیند و نه کل سندی که تکه از آن استخراج شده است.

کار را با 300 کلمه و 50 کلمه هم‌پوشانی شروع کنید. زبان انگلیسی تقریباً 1.3 توکن در هر کلمه است، بنابراین 300 کلمه حدود 400 توکن می‌شود. هم‌پوشانی به این دلیل وجود دارد که اگر جمله‌ای روی مرز تکه‌ها قرار بگیرد، در غیر این صورت به دو نیم تقسیم می‌شود و هیچ‌کدام از آن دو نیمه نمی‌توانند به پرسش پاسخ دهند.

در اسنادی که ساختار دارند، ابتدا بر اساس ساختار آن‌ها را تقسیم کنید. ابتدا بر اساس سرتیترها و سپس بر اساس پاراگراف‌ها شکست ایجاد کنید و قانون اندازه ثابت را فقط در بخش‌هایی اعمال کنید که هنوز بیش از حد طولانی هستند. تکه‌ای که از وسط یک جمله شروع می‌شود، در پاسخ نهایی خوانایی بدی دارد، زیرا مدل دقیقاً همان چیزی را که به او داده‌اید، نقل‌قول می‌کند.

پیش از آنکه بتوانید تکه‌بندی را اندازه‌گیری کنید، آن را بهینه‌سازی (tune) نکنید. روش اندازه ثابت با هم‌پوشانی، قطعی (deterministic) است و اجرای مجدد آن هزینه کمی دارد؛ این ویژگی باعث می‌شود که به یک مبنا (baseline) تبدیل شود که می‌توانید عملکرد خود را نسبت به آن بهبود دهید. ابتدا سیستم پرس‌وجوی امتیازدهی (scoring query) را در مراحل بعدی بسازید و سپس هر بار فقط یک مورد را تغییر دهید.

Embedding on the same box, and what it costs in RAM and latency

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

nomic-embed-text is 137 million parameters and a 274 MB download as of August 2026. Check what it returns before you design a table around it.

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]))'

That prints 768. Your column type has to match that number exactly.

Two settings on this model catch people out.

The task prefix is not optional. Nomic's model card says the input "must include a task instruction prefix". Documents are embedded with search_document: in front, questions with search_query: . Leave them out and nothing fails: you get vectors back, retrieval quality drops, and no log line anywhere tells you why.

Long input is truncated quietly. The /api/embed endpoint takes a truncate field and it defaults to true, and the model as packaged by Ollama advertises a 2K context. A chunk longer than that is cut at the limit and embedded anyway, so its tail is unsearchable. Send "truncate": false while you are testing, so an oversized chunk fails instead of passing.

Batch the requests, and keep the model resident.

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 accepts a list, and one request carrying 32 chunks beats 32 requests, because the HTTP round trip and the model lookup happen once instead of 32 times. keep_alive controls how long the model stays in memory after a request, and the default is 5 minutes. When it expires, the next request pays the load time again.

Measure the two numbers that matter on your own box. They depend on your vCPU count, so no published figure will match.

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 prints the resident size of the loaded model, which is RAM you have committed for as long as keep_alive holds it. The time output divided by the batch size is your seconds per chunk. Multiply by the chunk count to get the one time indexing cost. On a CPU only plan, expect a 100,000 chunk corpus to take hours rather than minutes. That is fine, because it happens once and it can run overnight under nice -n 19. If hours is not fine, the real question is whether renting a GPU pays for itself, and that is a break even calculation against API tokens rather than a preference.

If the box already serves a chat model, the embedding model is a second resident model and the RAM adds up. Running Ollama on a VPS covers sizing the generation side, and what a self-hosted model does under concurrent users covers what happens when several people ask at once. The embedding model is small enough to sit beside either.

The indexing script, start to finish

On Ubuntu 24.04 a plain pip install outside a virtual environment stops with error: externally-managed-environment, because the system Python belongs to 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() is yours: whatever walks your files or rows and yields a document id and its text. Everything else is the pipeline.

ذخیره‌سازی: طرح 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) سرور شما مطابقت داشته باشد. سپس نقش (role)، پایگاه داده و افزونه را ایجاد کنید.

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) از داده‌های برداری را اشغال می‌کنند. همین پیکره (corpus) اگر توسط یک مدل میزبانی‌شده با 1536 بعد تبدیل به بردار شود، به 587 مگابایت فضا نیاز دارد و ایندکس روی آن نیز به همان نسبت رشد می‌کند. استفاده از دقت نیم‌بیت (half precision) هر دو مقدار را نصف می‌کند: halfvec(768) آن پیکره را در 147 مگابایت ذخیره می‌کند. اینکه آیا این کار باعث کاهش دقت بازیابی (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) دیگر سرعت کافی نداشت، ایندکس را اضافه کنید و این مبادله را در نظر داشته باشید: یک ایندکس تقریبی، همسایگان تقریبی را برمی‌گرداند.

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.

این یک خطا نیست و فرآیند ساخت همچنان به پایان می‌رسد، اما به مسیر بسیار کندتری می‌افتد. مقدار maintenance_work_mem را در همان نشست (session) که ایندکس را می‌سازد افزایش دهید و مقدار پیش‌فرض سرور را تغییر ندهید؛ زیرا این تنظیم برای هر عملیات نگهداری است و مقدار سراسری بالا می‌تواند باعث اتمام حافظه سرور شود. پیشرفت یک ساخت طولانی را از نشست دوم دنبال کنید.

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;

مقدار بالاتر باعث جستجوی بیشتر در گراف، یافتن همسایگان بهتر و افزایش تأخیر (latency) می‌شود. این یک تنظیم نشست است، بنابراین می‌توانید آن را برای یک پرس‌وجو بدون تغییر در ایندکس افزایش دهید.

اگر یک پرس‌وجو اصلاً از ایندکس استفاده نمی‌کند، طرح اجرا (plan) آن را نشان می‌دهد.

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

اسکن ترتیبی در اینجا اغلب به فضای ذخیره‌سازی مربوط می‌شود. یک بردار 768 بعدی 3,080 بایت است که بیش از مقداری است که Postgres به‌صورت inline نگه می‌دارد، بنابراین مقدار به جدول TOAST (محل ذخیره‌سازی خارج از خط برای مقادیر بزرگ) منتقل می‌شود. نکته خود pgvector این است که برنامه‌ریز (planner) فضای ذخیره‌سازی خارج از خط را در برآوردهای هزینه خود لحاظ نمی‌کند، که می‌تواند باعث شود اسکن ترتیبی ارزان‌تر از آنچه هست به نظر برسد. ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; بردارها را به‌صورت inline نگه می‌دارد. این تنظیم برای ردیف‌هایی که پس از تغییر نوشته می‌شوند اعمال می‌شود، بنابراین ردیف‌های موجود نیاز به بازنویسی جدول (table rewrite) دارند.

بازیابی: یک پرس‌وجو، دو سیگنال

جستجوی برداری (Vector search)، متونی را پیدا می‌کند که معنایی مشابه با پرسش دارند. این روش در تطبیق دقیق رشته‌ها مانند شماره قطعه، کد خطا یا نام خانوادگی ضعیف عمل می‌کند. جستجوی کلمه‌کلیدی (Keyword search) دقیقاً برعکس است و Postgres از قبل آن را پشتیبانی می‌کند. به‌جای راه‌اندازی یک سیستم دوم، این دو را در یک پرس‌وجو ترکیب کنید.

ترکیب رتبه معکوس (Reciprocal rank fusion) ساده‌ترین روش ترکیبی کارآمد است. هر نتیجه از هر فهرستی که در آن ظاهر شود، 1 / (60 + rank) دریافت می‌کند و این دو امتیاز با هم جمع می‌شوند. این روش نیازی به نرمال‌سازی امتیاز ندارد، زیرا به‌جای فواصل، موقعیت‌ها را می‌خواند.

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 همان embedding پرسش از مدل مشابه است که با پیشوند search_query: ساخته شده است. $2 همان پرسش به صورت متن است. هر دو از سمت اپلیکیشن شما مقداردهی می‌شوند. websearch_to_tsquery یک پرسش واقعی کاربر را بدون مشکل در مواجهه با علائم نگارشی می‌پذیرد، کاری که to_tsquery قادر به انجام آن نیست. یک نکته دیگر: افزودن فیلتر WHERE روی یک اسکن HNSW ممکن است باعث بازگشت ردیف‌های کمتری نسبت به تعداد درخواستی شما شود، زیرا ابتدا ایندکس جستجو شده و سپس فیلتر اعمال می‌شود. SET hnsw.iterative_scan = relaxed_order; باعث می‌شود pgvector تا زمانی که به تعداد کافی ردیف دست یابد، به اسکن ادامه دهد.

چگونه می‌توان فهمید که بازیابی (retrieval) کیفیت مطلوبی دارد؟

این مرحله‌ای است که تقریباً تمام راهنماهای RAG از آن عبور می‌کنند، در حالی که تنها راه برای تشخیص این است که آیا انتخاب‌های دیگر شما مفید بوده‌اند یا خیر. برای این کار نیازی به یک چارچوب ارزیابی پیچیده نیست؛ تنها به 30 سوال و شناسه (id) قطعه‌متنی (chunk) که پاسخ هر سوال در آن قرار دارد، نیاز دارید.

این سوالات را دستی بنویسید. سوالاتی را انتخاب کنید که کاربران واقعاً درباره این مجموعه داده (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: تعبیه (embed) کنید، آن را ذخیره کرده و سپس کل مجموعه را در یک کوئری امتیازدهی کنید.

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 (میانگین رتبه معکوس)، عدد 1 را بر موقعیت قطعه‌متن صحیح تقسیم کرده و موارد ناموفق را صفر در نظر می‌گیرد؛ بنابراین به رتبه‌بندی پاسخ در جایگاه اول نسبت به جایگاه هشتم، امتیاز بیشتری می‌دهد. هر دو عدد با تغییر اندازه قطعه‌متن، تعویض مدل embedding یا افزودن جستجوی کلمات کلیدی تغییر می‌کنند و اکنون می‌توانید جهت این تغییرات را مشاهده کنید.

اولویت اصلی خود را حفظ recall at 10 قرار دهید، زیرا مدل تولیدکننده (generator) نمی‌تواند از قطعه‌متنی که هرگز دریافت نکرده است، استفاده کند. زمانی که recall at 10 برابر با 0.9 است و پاسخ‌ها همچنان اشتباه هستند، مشکل از prompt یا مدل است، نه از بازیابی. همین تفکیک ساده، شما را از روزها حدس و گمان نجات می‌دهد.

ایندکس را به‌صورت جداگانه بررسی کنید. جستجوی تقریبی (approximate search) باعث کاهش recall می‌شود و pgvector به شما نشان می‌دهد که این کاهش چقدر است: همان کوئری را با جستجوی دقیق (exact search) اجرا کرده و شناسه‌ها را مقایسه کنید.

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

اگر نه شناسه از ده شناسه مشترک باشد، یعنی ef_search مناسب است. اگر چهار شناسه از ده شناسه مشترک باشد، باید مقدار آن را افزایش دهید.

بازرتبه‌بندی و تولید: جایی که یک API درآمد کسب می‌کند

مدل بازرتبه‌بندی (Reranker) نوع متفاوتی از مدل است. این مدل پرسش و یک قطعه متن (chunk) را با هم می‌خواند و به آن جفت امتیاز می‌دهد؛ این روش از مقایسه دو embedding که به‌طور مستقل محاسبه شده‌اند دقیق‌تر است، اما برای اجرا روی کل بدنهٔ داده‌ها (corpus) بسیار کند عمل می‌کند. دقیقاً به همین دلیل است که جایگاه آن اینجاست. این مدل 40 کاندیدایی را که مرحله بازیابی (retrieval) برگردانده است بررسی می‌کند، نه 100,000 قطعه موجود در جدول را. بنابراین، یک API بازرتبه‌بندی میزبانی‌شده، هزینه 40 جفت کوتاه را به ازای هر پرسش دریافت می‌کند و موارد مثبت کاذب (false positives) ضعیف را پیش از رسیدن به مرحله پرهزینه حذف می‌کند.

تولید (Generation) هزینهٔ تکرارشونده است و دو اهرم بر آن تأثیر می‌گذارند. قطعات کمتری ارسال کنید؛ با استفاده از معیار recall در 10، بررسی کنید که تا چه حد می‌توانید تعداد قطعات را بدون از دست دادن پاسخ‌ها کاهش دهید. بخش ابتدایی prompt را بایت‌به‌بایت ثابت نگه دارید تا prompt cache ارائه‌دهنده بتواند از آن استفاده کند، و قطعات بازیابی‌شده را پس از آن بخش ثابت قرار دهید. پاسخ‌های نهایی را نیز بر اساس پرسش کش (cache) کنید، زیرا ارزان‌ترین توکن تولیدشده، توکنی است که هفتهٔ گذشته تولید کرده‌اید.

تعیین ابعاد سرور و زمانی که این ظرفیت دیگر کافی نیست

هر قاعدهٔ تعیین ابعادی که در اینجا ذکر شده، چیزی است که باید اندازه‌گیری شود، نه اینکه تخمین زده شود.

  • حافظهٔ RAM محدودیت اصلی است: مجموع اندازهٔ مدل مقیم در حافظه از ollama ps، به‌علاوهٔ اندازهٔ ایندکس HNSW، به‌علاوهٔ shared_buffers، با در نظر گرفتن فضای خالی کافی برای اتصالات و page cache.
  • دیسک به دو برابر فضای pg_total_relation_size('chunks') نیاز دارد، زیرا بازسازی یک ایندکس، هر دو نسخه را به‌طور هم‌زمان نگه می‌دارد.
  • CPU زمان بازسازی ایندکس (reindex) را تعیین می‌کند؛ این زمان برابر است با ثانیه‌های اندازه‌گیری‌شده برای هر chunk ضرب‌در تعداد کل chunkها.
  • بازسازی ایندکس بیشتر از آنچه انتظار دارید رخ می‌دهد، زیرا تغییر مدل embedding، تمام بردارهای ذخیره‌شدهٔ قبلی را نامعتبر می‌کند.

این طراحی در نقطه‌ای که می‌توانید از قبل پیش‌بینی کنید، دیگر کافی نخواهد بود. زمانی که ایندکس HNSW دیگر در RAM قابل‌خرید شما جا نمی‌شود، تأخیر پرس‌وجو (query latency) به جستجوهای دیسک (disk seeks) تبدیل می‌شود و هیچ تنظیمی نمی‌تواند آن را بازیابی کند. زمانی که یک جدول به مستأجران (tenants) زیادی سرویس می‌دهد و هر پرس‌وجو بر اساس مستأجر فیلتر می‌شود، پارتیشن‌بندی جدول راه‌حل است و این کار، یک عملیات جدی محسوب می‌شود. زمانی که عملیات نوشتن ایندکس و پرس‌وجوهای کاربران بر سر یک سرور با هم رقابت می‌کنند، پیش از جابه‌جایی دیتابیس، worker مربوط به embedding را به سرور دوم منتقل کنید. تا زمانی که یکی از این موارد رخ نداده است، Postgres به همراه pgvector روی VPSای که هم‌اکنون اجاره کرده‌اید، یک پاسخ عملیاتی (production) است و اعداد بالا به شما می‌گویند که چقدر تا رسیدن به محدودیت فاصله دارید.

FAQ

آیا می‌توانم یک pipeline مدل RAG را روی یک VPS اجرا کنم یا به دیتابیس برداری نیاز دارم؟

یک VPS برای مجموعه‌داده‌هایی با صدها هزار تکه (chunk) کافی است. با ابعاد 768، تعداد 100,000 تکه حدود 294 مگابایت داده برداری به علاوه متن و ایندکس HNSW اشغال می‌کند که در RAM یک پلن معمولی جای می‌گیرد. محدودیت اصلی حافظه است، نه تعداد ردیف‌ها؛ زیرا جستجوی HNSW در ایندکس جابه‌جا می‌شود و به محض اینکه ایندکس در RAM جا نشود، تأخیر (latency) به‌شدت افزایش می‌یابد. با مقایسه pg_relation_size روی ایندکس و free -m، متوجه وضعیت فعلی خود خواهید شد.

آیا برای embedding اسناد به GPU نیاز دارم؟

اگر فقط یک‌بار embedding انجام می‌دهید و سپس کوئری می‌گیرید، خیر. یک مدل 137 میلیون پارامتری مانند nomic-embed-text روی CPU اجرا می‌شود و یک دور کامل روی یک مجموعه بزرگ، ساعاتی زمان می‌برد که می‌توانید آن را به شب موکول کنید. GPU زمانی اهمیت پیدا می‌کند که اسناد به‌طور مداوم وارد شوند یا بخواهید عملیات تولید (generation) را روی همان سرور انجام دهید. زمان یک batch را با /api/embed روی سرور خود بسنجید و در تعداد تکه‌ها ضرب کنید، زیرا تعداد vCPUها آن‌قدر متفاوت است که ارقام منتشرشده کمکی به شما نمی‌کنند.

چرا کوئری برداری من به‌جای استفاده از ایندکس HNSW، از اسکن ترتیبی (sequential scan) استفاده می‌کند؟

طرح اجرا (plan) را با EXPLAIN (ANALYZE, BUFFERS) بخوانید. دلیل رایج، نحوه ذخیره‌سازی است: pgvector اشاره می‌کند که planner در تخمین هزینه‌ها، فضای ذخیره‌سازی out-of-line را محاسبه نمی‌کند؛ این باعث می‌شود اسکن ترتیبی ارزان‌تر از آنچه هست به نظر برسد. یک بردار 768 بعدی 3,080 بایت است، بنابراین به‌طور پیش‌فرض در جدول TOAST قرار می‌گیرد. ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; ردیف‌های جدید را inline نگه می‌دارد. دو دلیل دیگر عبارتند از: استفاده از عملگری که با ایندکس مطابقت ندارد (چون ایندکسی که با vector_cosine_ops ساخته شده، فقط توسط <=> استفاده می‌شود) و کوئری بدون ORDER BY ... LIMIT، زیرا ایندکس تقریبی فقط برای کوئری‌های نزدیک‌ترین همسایه مرتب‌شده (ordered nearest neighbour) کاربرد دارد.

چگونه بفهمم بازیابی (retrieval) من کیفیت مناسبی دارد؟

یک مجموعه طلایی (gold set) شامل 30 سوال بسازید که هر کدام با شناسه تکه‌ای که پاسخ آن است جفت شده باشد، و embedding سوالات را در کنار آن‌ها ذخیره کنید. سپس recall در 10 را اندازه بگیرید (اینکه چند بار تکه درست در 10 نتیجه اول ظاهر می‌شود) و همچنین MRR که به رتبه‌بندی در جایگاه اول امتیاز می‌دهد. این دو عدد به شما می‌گویند که آیا تغییر در اندازه تکه، مدل embedding یا rank fusion تأثیر مثبت داشته است یا خیر. بدون این معیارها، شما فقط تنظیمات را تغییر می‌دهید و به برداشت شخصی خود از چند پاسخ محدود تکیه می‌کنید.