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

راهنمای اجرای پایگاه داده برداری روی VPS

با اجرای pgvector، Qdrant یا Chroma روی یک VPS، تاخیر شبکه را حذف کنید. در این مطلب نحوه محاسبه دقیق RAM مورد نیاز برای ایندکس‌ها و مقایسه عملکرد هر ابزار را بررسی می‌کنیم.

هزینه واقعی اجرای یک پایگاه‌داده برداری روی VPS

اجرای یک پایگاه‌داده برداری روی یک VPS (سرور مجازی خصوصی)، مشکلی را که تمام ارائه‌دهندگان سرویس‌های مدیریت‌شده برای حل آن هزینه دریافت می‌کنند، از بین می‌برد. برنامه و ایندکس شما روی یک ماشین واحد قرار دارند، بنابراین درخواست جستجو به‌جای عبور از شبکه، از یک loopback socket عبور می‌کند. آنچه باقی می‌ماند، هزینه‌ای است که همیشه هزینه اصلی بوده است: تبدیل متن به بردار. در پس‌زمینه این فرآیند، دو عامل دیگر نیز وجود دارد: زمان لازم برای ساخت ایندکس و میزان RAM که ایندکس تا زمانی که فعال است، اشغال می‌کند.

این موضوع باعث تغییر اولویت تصمیم‌گیری‌ها می‌شود. منطقه جغرافیایی (Region) و زمان رفت‌وبرگشت به endpoint دیگر دغدغه شما نیستند. حاصل‌ضرب تعداد بردارها، ابعاد و عدد 4 بایت، به دغدغه اصلی شما تبدیل می‌شود؛ زیرا این مقدار تعیین می‌کند که آیا ایندکس در حافظه‌ای که ماهانه اجاره می‌کنید، جای می‌گیرد یا خیر.

میلی‌ثانیه‌ها در یک سرور دقیقاً کجا صرف می‌شوند

یک پرس‌وجوی مشابهت (similarity query) را در یک استک self-hosted دنبال کنید.

  1. متن پرس‌وجو توسط یک مدل embedding به بردار تبدیل می‌شود. روی CPU این فرآیند برای یک رشته کوتاه، ده‌ها تا صدها میلی‌ثانیه زمان می‌برد. روی GPU این مقدار تک‌رقمی است.
  2. بردار به store ارسال می‌شود. از طریق loopback TCP یا یک Unix domain socket، این کار کسری از میلی‌ثانیه زمان می‌برد.
  3. استور ایندکس خود را پیمایش کرده و نزدیک‌ترین ردیف‌ها را برمی‌گرداند.
  4. کد شما متن منطبق را می‌خواند و یک prompt سرهم می‌کند.

مرحله 1 معمولاً بزرگ‌ترین عدد در این لیست است. مرحله 2 همان مرحله‌ای است که فروشندگان سرویس‌های ابری بر سر آن رقابت می‌کنند، اما در یک سرور واحد، این زمان تقریباً وجود ندارد. حدس نزنید که زمان کجا صرف می‌شود. هر دو سمت را روی سرور خودتان اندازه‌گیری کنید.

curl http://127.0.0.1:11434/api/embed -s -o /dev/null \
  -w 'embed: %{time_total}s\n' \
  -d '{"model": "nomic-embed-text", "input": "how do I rotate the api key"}'

سپس قبل از پرس‌وجوی جستجو، \timing on را در psql اجرا کنید. اگر دستور اول embed: 0.184312s را چاپ کرد و psql پاسخ Time: 4.201 ms را داد، تنظیم کردن ایندکس کار اشتباهی است: تأخیر (latency) شما مربوط به مدل embedding است. اجرای محلی مدل embedding با Ollama مرحله 1 را روی همان CPUای قرار می‌دهد که مراحل 2 و 3 روی آن اجرا می‌شوند، بنابراین هر دو نیمه برای هسته‌ها و RAM یکسان با هم رقابت می‌کنند. حلقه ingest و بازیابی که دور این استور پیچیده شده است، در راهنمای پایپ‌لاین RAG به صورت self-hosted پوشش داده شده است. RAG مخفف retrieval augmented generation است: شما اسناد خود را جستجو می‌کنید و بهترین موارد منطبق را در یک prompt قرار می‌دهید.

برای کمتر از صد هزار بردار، همه آن‌ها را اسکن کنید

یک اسکن جامع، پرس‌وجو را با تک‌تک بردارهای ذخیره‌شده مقایسه می‌کند. نرخ بازیابی (Recall) طبق تعریف کامل است. این روش نیازی به ایندکس یا مرحله ساخت ندارد و به دلیل قدیمی شدن داده‌ها، دچار انحراف (drift) نمی‌شود.

محاسبات ریاضی به شما می‌گوید که این روش چه زمانی دیگر کارآمد نیست. یک اسکن به ازای هر پرس‌وجو، n * d * 4 بایت را می‌خواند، که در آن n تعداد بردارها و d ابعاد آن‌هاست. برای 100,000 بردار با 768 بعد، این مقدار 307 مگابایت در هر پرس‌وجو است که یک پردازنده مدرن آن را در چند ده میلی‌ثانیه پردازش می‌کند. برای 5 میلیون بردار، این مقدار به 15 گیگابایت در هر پرس‌وجو می‌رسد که دیگر یک پرس‌وجو محسوب نمی‌شود.

بنابراین، بردارها را در SQLite ذخیره کنید و مقایسه را در NumPy انجام دهید.

import sqlite3, numpy as np

db = sqlite3.connect("docs.db")
db.execute("CREATE TABLE IF NOT EXISTS docs (id INTEGER PRIMARY KEY, body TEXT, vec BLOB)")

def add(body, vec):
    v = np.asarray(vec, dtype=np.float32)
    v /= np.linalg.norm(v)
    db.execute("INSERT INTO docs (body, vec) VALUES (?, ?)", (body, v.tobytes()))
    db.commit()

def search(query_vec, k=5):
    rows = db.execute("SELECT id, body, vec FROM docs").fetchall()
    mat = np.frombuffer(b"".join(r[2] for r in rows), dtype=np.float32).reshape(len(rows), -1)
    q = np.asarray(query_vec, dtype=np.float32)
    q /= np.linalg.norm(q)
    scores = mat @ q
    return [(rows[i][0], rows[i][1], float(scores[i])) for i in np.argsort(-scores)[:k]]

هر دو طرف به طول واحد (unit length) مقیاس‌بندی شده‌اند، بنابراین ضرب داخلی (dot product) همان شباهت کسینوسی (cosine similarity) است و امتیاز بالاتر به معنای شباهت بیشتر است. فایل mat را یک‌بار در زمان شروع برنامه بارگذاری کنید، نه در هر پرس‌وجو؛ با این کار، عملیات خواندن از SQLite کاملاً از مسیر اصلی (hot path) خارج می‌شود.

پیش از رد کردن این روش، آن را روی سیستم خودتان زمان‌بندی کنید.

import time
t = time.perf_counter()
search(q)
print(f"{(time.perf_counter() - t) * 1000:.1f} ms")

محدودیت‌های صادقانه: این یک پردازش واحد است که کل ماتریس را در RAM نگه می‌دارد و هیچ قابلیت فیلترینگ متادیتا یا مدیریت هم‌زمان نویسنده‌ها (concurrent writer) را ارائه نمی‌دهد. زمانی که یکی از این موارد دلیل نارضایتی شما شد، به سراغ راهکار دیگری بروید. خود SQLite یک ذخیره‌ساز جدی در سمت سرور است که راهنمای استفاده از SQLite در محیط عملیاتی به بررسی آن می‌پردازد، و اگر حجم کاری واقعی شما به جای ارائه ردیف‌ها، اسکن ستون‌هاست، مقایسه DuckDB و SQLite خواندنی‌تر و مفیدتر خواهد بود.

استفاده از pgvector در صورتی که از قبل Postgres دارید

اگر برنامهٔ شما در حال حاضر از دیتابیس Postgres استفاده می‌کند، pgvector کمترین سطح حمله و پیچیدگی جدید را ایجاد می‌کند. این ابزار یک extension است، نه یک سرویس مجزا. بردارها در یک جدول معمولی در کنار ردیفی که توصیف می‌کنند قرار می‌گیرند، بنابراین یک جستجوی فیلترشده تنها یک عبارت WHERE است و نیازی به همگام‌سازی با سیستم دوم نیست.

اوبونتو 24.04 این افزونه را در مخزن universe خود ارائه می‌دهد.

sudo apt update
sudo apt install -y postgresql-16-pgvector
sudo -u postgres psql -d yourdb -c 'CREATE EXTENSION vector;'

این بسته تا اوت 2026 نسخه 0.6.0 از pgvector است که از نسخه اصلی (upstream) عقب‌تر است. به‌ویژه برای اسکن‌های ایندکس تکرار شونده (Iterative index scans) به نسخه 0.8 نیاز دارید، بنابراین آن را از مخزن رسمی پروژه PostgreSQL دریافت کنید.

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

عبارت 17 را با نسخه اصلی (major version) سرور خود جایگزین کنید؛ نسخه‌ای که دستور sudo -u postgres psql -tAc 'SHOW server_version' آن را نمایش می‌دهد. اگر بسته extension را برای نسخه اصلی اشتباه نصب کنید، CREATE EXTENSION با خطا مواجه می‌شود، زیرا Postgres فقط در دایرکتوری share مربوط به نسخه‌ای که در حال اجراست جستجو می‌کند:

ERROR:  could not open extension control file "/usr/share/postgresql/16/extension/vector.control": No such file or directory

طرح‌واره (schema) همان SQL معمولی با یک نوع داده جدید است.

CREATE TABLE chunks (
  id        bigserial PRIMARY KEY,
  doc_id    bigint NOT NULL,
  body      text   NOT NULL,
  embedding vector(768)
);

SELECT id, body FROM chunks
ORDER BY embedding <=> '[0.013, -0.021, 0.004]'
LIMIT 5;

عبارت <=> برای فاصله کسینوسی (cosine distance)، <-> برای فاصله اقلیدسی (L2) و <#> برای ضرب داخلی منفی (negative inner product) است. از گزینه‌ای استفاده کنید که مدل embedding شما برای آن آموزش دیده است. اگر گزینه اشتباه را انتخاب کنید، خطایی رخ نمی‌دهد، اما نتایج شما به‌طور نامحسوسی ضعیف‌تر خواهند بود.

بدون ایندکس، این کوئری یک جستجوی دقیق روی تمام ردیف‌هاست که معادل نسخه Postgres همان روش brute force است و دقت (recall) کامل مشابهی دارد. افزایش max_parallel_workers_per_gather باعث می‌شود هسته‌های بیشتری برای پردازش آن به کار گرفته شوند. این کار را ابتدا انجام دهید و ایندکس‌گذاری را به بعد موکول کنید، زیرا اکنون یک مبنای دقت (recall baseline) برای سنجش عملکرد ایندکس در اختیار دارید.

Qdrant، زمانی که ایندکس از پایگاه داده بزرگ‌تر می‌شود

Qdrant یک ذخیره‌ساز برداری اختصاصی است که با زبان Rust نوشته شده است. زمانی استفاده از آن توجیه دارد که ایندکس به‌قدری بزرگ باشد که نخواهید فرآیند ساخت آن با Postgres برنامه شما رقابت کند، یا زمانی که به قابلیت‌های فیلترینگ payload و کوانتیزاسیون (quantisation) نیاز دارید که pgvector ارائه نمی‌دهد.

docker run -d --name qdrant \
  -p 127.0.0.1:6333:6333 -p 127.0.0.1:6334:6334 \
  -e QDRANT__SERVICE__API_KEY="$(openssl rand -hex 32)" \
  -v "$(pwd)/qdrant_storage:/qdrant/storage:z" \
  qdrant/qdrant

پورت 6333 به API از نوع REST (انتقال حالت بازنمودی) و داشبورد در /dashboard سرویس‌دهی می‌کند و پورت 6334 برای gRPC است. دو نکته در مورد VPS عمومی اهمیت دارد. مستندات خود Qdrant می‌گوید که این سرویس به‌صورت پیش‌فرض «بدون رمزنگاری یا احراز هویت» اجرا می‌شود و -p 6333:6333 در راهنمای شروع سریع، به تمام اینترفیس‌ها متصل (bind) می‌شود؛ این در حالی است که Docker آن را از سد قوانین ufw عبور می‌دهد، زیرا قوانین forwarding اختصاصی خود را می‌نویسد. سرویس را به 127.0.0.1 متصل کنید و یک API key تنظیم کنید. نمونه‌ای از Qdrant که روی یک IP عمومی بدون کلید در دسترس باشد، در واقع یک کپی عمومی از اسناد شماست.

curl -s http://127.0.0.1:6333/collections -H "api-key: $QDRANT_API_KEY"

یک پاسخ سالم شبیه {"result":{"collections":[]},"status":"ok","time":0.00002} است. دریافت {"status":{"error":"Unauthorized"}} به این معنی است که نام هدر یا کلید اشتباه است، و دریافت نکردن هیچ پاسخی به این معنی است که کانتینر در حال اجرا نیست یا در جای دیگری متصل شده است. اینکه آیا این کانتینر برای سرور شما مناسب است یا خیر، همان پرسش همیشگی در مورد سرویس‌های stateful است، بنابراین مقایسه دیتابیس Docker در برابر میزبان در اینجا نیز بدون تغییر صدق می‌کند.

Chroma و کاربرد آن

Chroma کوتاه‌ترین مسیر از نقطه صفر تا رسیدن به یک دمو عملی برای بازیابی اطلاعات (retrieval) است.

pip install chromadb
chroma run --path /srv/chroma

این ابزار روی پورت 8000 گوش می‌دهد و chromadb.HttpClient(host="localhost", port=8000) به آن متصل می‌شود. Chroma به‌صورت پیش‌فرض یک تابع embedding ارائه می‌دهد، بنابراین برای ساخت اولین نمونه اولیه (prototype) نیازی به سرور مدل جداگانه ندارید.

در مورد این معامله صادق باشید. Chroma به این دلیل خوش‌دست است که تصمیماتی را که این راهنما درباره آن‌هاست پنهان می‌کند: کدام معیار فاصله (distance metric) استفاده شود و نتیجه چقدر از حافظه RAM را اشغال کند. این رویکرد برای یک نمونه اولیه درست است، اما برای سیستمی که بابت آن به شما هشدار (page) داده می‌شود، اشتباه است. اگر داده‌های شما هم‌اکنون در Postgres قرار دارند، انتقال آن‌ها به Chroma یک پردازش اضافی و مشکل همگام‌سازی ایجاد می‌کند تا مسئله‌ای را حل کنید که pgvector اصلاً با آن مواجه نیست.

شاخص به چه مقدار رم نیاز دارد

از بردارهای خام شروع کنید، زیرا آن‌ها کفِ نیاز هستند و هیچ بهینه‌سازی‌ای نمی‌تواند این مقدار را کاهش دهد.

bytes = number_of_vectors * dimensions * 4

هر بعد، یک عدد اعشاری 32 بیتی است که معادل 4 بایت می‌باشد. مستندات برنامه‌ریزی ظرفیت Qdrant، یک ضریب 1.5 برای متادیتا و سگمنت‌های موقتی که در طول بهینه‌سازی ایجاد می‌شوند، در نظر می‌گیرد:

memory_size = number_of_vectors * vector_dimension * 4 bytes * 1.5

در اینجا این فرمول برای یک میلیون بردار، با ابعادی که مدل‌های embedding واقعی تولید می‌کنند، محاسبه شده است:

ChartRAM for 1 million vectors, by embedding dimension
The data behind this chart
[
  {
    "label": "384 dims",
    "raw_gib": 1.43,
    "with_overhead_gib": 2.15
  },
  {
    "label": "768 dims",
    "raw_gib": 2.86,
    "with_overhead_gib": 4.29
  },
  {
    "label": "1024 dims",
    "raw_gib": 3.81,
    "with_overhead_gib": 5.72
  },
  {
    "label": "1536 dims",
    "raw_gib": 5.72,
    "with_overhead_gib": 8.58
  },
  {
    "label": "3072 dims",
    "raw_gib": 11.44,
    "with_overhead_gib": 17.17
  }
]

این اعداد خروجی فرمول به گیبی‌بایت (GiB) هستند و نه یک اندازه‌گیری واقعی. آن‌ها را به عنوان حجم فضایی که باید در حافظه خالی بگذارید، در نظر بگیرید. یک مدل 768 بعدی مانند nomic-embed-text برای یک میلیون تکه (chunk)، به حدود 4.29 گیبی‌بایت رم نیاز دارد که در یک پلن 8 گیگابایتی با فضای کافی برای Postgres جای می‌گیرد. همین مجموعه داده با 3072 بعد، به 17.17 گیبی‌بایت نیاز دارد و در آن پلن جا نمی‌شود.

اهرم اصلی، ستون اول آن جدول است، نه ستون آخر. نصف کردن ابعاد، تمام بایت‌های بعدی آن را برای همیشه نصف می‌کند. مدلی با 768 بعد که در یک لیدربورد عمومی امتیاز کمی پایین‌تری می‌گیرد، اغلب انتخاب مهندسی بهتری برای یک VPS است. نوع داده halfvec در pgvector، اعداد اعشاری 16 بیتی را ذخیره می‌کند که بایت‌ها را دوباره نصف می‌کند و از طریق یک عبارت، ایندکس‌گذاری را انجام می‌دهد:

CREATE INDEX ON chunks USING hnsw ((embedding::halfvec(768)) halfvec_cosine_ops);

یک محدودیت که باید پیش از انتخاب مدل بدانید: نوع داده vector در pgvector تا 16,000 بعد را می‌پذیرد، اما ایندکس‌های HNSW و IVFFlat آن تنها تا 2,000 بعد را پوشش می‌دهند. بالاتر از آن، باید یک cast به نوع halfvec را ایندکس کنید که تا 4,000 بعد را پشتیبانی می‌کند، یا اینکه اصلاً ایندکس‌گذاری نکنید.

هزینه m و ef_construction در زمان ساخت

HNSW (گراف کوچک‌جهان‌پیمای سلسله‌مراتبی) ایندکسی است که هم pgvector و هم Qdrant از آن استفاده می‌کنند. این یک گراف لایه‌بندی‌شده است. هر بردار یک گره با لینک‌هایی به گره‌های مجاور است و جستجو به‌جای خواندن همه داده‌ها، با پرش از طریق این لینک‌ها به سمت پرس‌وجو حرکت می‌کند.

m تعداد لینک‌هایی است که هر گره حفظ می‌کند. مستندات Faiss حافظه مورد نیاز HNSW را (d * 4 + m * 2 * 4) بایت به ازای هر بردار اعلام کرده و توصیه می‌کند m بین 4 و 64 نگه داشته شود. این تنظیمات را برای 768 بعد روی یک میلیون بردار اجرا کنید.

ChartCost of raising m at 768 dimensions, 1 million vectors
The data behind this chart
[
  {
    "label": "m = 8",
    "link_bytes_per_vector": 64,
    "total_gib": 2.92
  },
  {
    "label": "m = 16 (default)",
    "link_bytes_per_vector": 128,
    "total_gib": 2.98
  },
  {
    "label": "m = 32",
    "link_bytes_per_vector": 256,
    "total_gib": 3.1
  },
  {
    "label": "m = 64",
    "link_bytes_per_vector": 512,
    "total_gib": 3.34
  }
]

نکته غافلگیرکننده و مفید، اندازه این تفاوت است. تغییر از مقدار پیش‌فرض m = 16 به m = 64، به میزان 512 بایت لینک به ازای هر بردار در برابر 3072 بایت داده برداری اضافه می‌کند، بنابراین مجموع از 2.98 گیگابایت به 3.34 گیگابایت تغییر می‌کند. این حدود 12 درصد است. در این ابعاد، m جایی نیست که حافظه شما مصرف می‌شود؛ بلکه بردارها عامل اصلی مصرف حافظه هستند.

هزینه واقعی m در زمان ساخت و زمان درج نهفته است، زیرا قرار دادن یک گره به معنای یافتن و لینک‌دهی به آن تعداد همسایه است. ef_construction اندازه لیست کاندیداهایی است که سازنده هنگام قرار دادن هر گره در نظر می‌گیرد. افزایش آن باعث ایجاد گراف بهتر و ساخت کندتر می‌شود و هیچ تغییری در اندازه نهایی ایندکس ایجاد نمی‌کند.

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

maintenance_work_mem تنظیمی است که تعیین می‌کند ساخت ایندکس چند دقیقه طول می‌کشد یا چند ساعت، زیرا pgvector گراف را در صورت وجود فضای کافی، در حافظه (RAM) اسمبل می‌کند. وقتی فضا تمام شود، pgvector این موضوع را اعلام می‌کند:

NOTICE:  hnsw graph no longer fits into maintenance_work_mem after 100000 tuples
DETAIL:  Building will take significantly more time.
HINT:  Increase maintenance_work_mem to speed up builds.

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

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

HNSW مقادیر initializing و سپس loading tuples را گزارش می‌دهد. اگر ساخت ایندکس برای مدت طولانی روی یک درصد پایین باقی بماند و دیسک نیز مشغول نباشد، این مشکل maintenance_work_mem است، نه یک پرس‌وجوی گیرکرده.

دو واقعیت که ارزش برنامه‌ریزی دارند: فایل README در pgvector می‌گوید HNSW نسبت به IVFFlat "زمان ساخت کندتر و مصرف حافظه بیشتری دارد" و در عوض تعادل بهتری بین سرعت و دقت (recall) ارائه می‌دهد. همچنین HNSW را می‌توان روی یک جدول خالی ایجاد کرد، در حالی که IVFFlat باید ابتدا الگوریتم k-means را روی داده‌های نمونه اجرا کند؛ بنابراین ساخت آن روی یک جدول خالی منجر به دقت پایین می‌شود. در یک شمای جدید، HNSW گزینه‌ای است که می‌توانید از همان ابتدا ایجاد کنید.

m و ef_construction در ایندکس ثابت هستند، اما ef_search این‌طور نیست. این پارامتر مشخص می‌کند که هنگام پیمایش گراف، چه تعداد کاندیدا برای جستجو حفظ شود و می‌توانید آن را برای هر نشست یا هر کوئری تغییر دهید.

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

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

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

SET hnsw.iterative_scan = relaxed_order;

در این حالت، ایندکس مجدداً برای یافتن کاندیداهای بیشتر اسکن می‌شود تا محدودیت مورد نظر برآورده شود؛ این کار تا سقف hnsw.max_scan_tuples که به‌صورت پیش‌فرض 20000 است، ادامه می‌یابد. strict_order ترتیب دقیق فاصله را حفظ می‌کند و هزینه بیشتری دارد. این همان قابلیتی است که در بسته 0.6.0 برای Ubuntu وجود ندارد و نبود ردیف‌ها هنگام استفاده از فیلتر، نشان‌دهنده عدم وجود این قابلیت است.

چرا ایندکس باید در RAM جای بگیرد

جستجوی HNSW در واقع پیمایش یک گراف است. هر گام (hop) در این گراف، گره‌ای را می‌خواند که در مکانی نامرتبط با گره قبلی ذخیره شده است؛ بنابراین الگوی دسترسی به حافظه نزدیک به تصادفی است و قابلیت read-ahead در اینجا کمکی نمی‌کند. تا زمانی که گراف در RAM قرار دارد، هر گام تنها یک ارجاع به حافظه است. اما وقتی گراف در RAM نباشد، هر گام می‌تواند به یک عملیات خواندن از دیسک تبدیل شود و جستجویی که چند صد گره را لمس می‌کند، به چند صد عملیات خواندن دیسک منجر خواهد شد.

مستندات Qdrant این موضوع را به شکل ملموسی بیان می‌کند: «اگر تعداد بردارها در RAM را به نصف کاهش دهید، تأخیر جستجو تقریباً دو برابر خواهد شد.» بر اساس همین جمله برنامه‌ریزی کنید.

زمانی که ایندکس واقعاً در حافظه جا نمی‌شود، هر گزینه‌ای که انتخاب می‌کنید یک بده‌بستان (trade-off) است که باید آگاهانه انجام شود.

  • استفاده از Memory-map برای بردارها تا سیستم‌عامل صفحات پرکاربرد (hot pages) را در حافظه کش کند و صفحات کم‌کاربرد (cold pages) را روی دیسک باقی بگذارد. برای قابل‌تحمل بودن این روش، به حافظه ذخیره‌سازی سریع NVMe در زیرساخت نیاز دارید.
  • استفاده از Quantisation؛ ذخیره هر بُعد به صورت یک بایت به جای چهار بایت. این کار حجم بردارها را به یک‌چهارم کاهش می‌دهد و در مقابل، هزینه ناچیز و قابل‌اندازه‌گیری در دقت بازیابی (recall) خواهد داشت.
  • تبدیل به halfvec در pgvector که حجم بایت‌ها را نصف می‌کند و نسبت به Quantisation یک‌بایتی، افت دقت بازیابی کمتری دارد.
  • استفاده از مدل‌های Embedding کوچک‌تر. این ارزان‌ترین راهکار است و معمولاً نادیده گرفته می‌شود، زیرا مستلزم Embedding مجدد کل مجموعه داده (corpus) است.

حالت‌های شکست و پیام‌هایی که مشاهده خواهید کرد

could not open extension control file. بسته pgvector برای نسخه اصلی Postgres در حال اجرا نصب نشده است. نسخه را با sudo -u postgres psql -tAc 'SHOW server_version' چاپ کنید و نسخه منطبق postgresql-NN-pgvector را نصب نمایید.

ERROR: expected 768 dimensions, not 1536. نوع ستون و مدل با هم همخوانی ندارند. شما مدل‌های embedding را تغییر داده‌اید و داده‌ها را دوباره embed نکرده‌اید. در اینجا هیچ راهکار جزئی وجود ندارد، زیرا بردارهای حاصل از دو مدل متفاوت به هیچ وجه قابل مقایسه نیستند، بنابراین هر ردیف باید دوباره تولید شود.

پرس‌وجو کند است و EXPLAIN یک sequential scan را نشان می‌دهد. کلاس عملگر ایندکس و عملگر پرس‌وجو با هم مطابقت ندارند. vector_cosine_ops فقط به <=> سرویس می‌دهد. دستور EXPLAIN ANALYZE را روی پرس‌وجو اجرا کنید و به دنبال Index Scan using ... on chunks بگردید. اگر به جای آن Seq Scan on chunks را مشاهده کردید، ایندکس را با کلاس عملگری که با عملگر مورد استفاده در پرس‌وجوی شما مطابقت دارد، بازسازی کنید.

تعداد ردیف‌ها کمتر از LIMIT است و یک عبارت WHERE وجود دارد. این همان تله فیلترینگ است که در بالا ذکر شد. مقدار hnsw.ef_search را افزایش دهید یا به pgvector 0.8 مهاجرت کرده و hnsw.iterative_scan را تنظیم کنید.

ساخت ایندکس با ناپدید شدن پردازش و بدون هیچ خطایی در psql به پایان می‌رسد. مقدار maintenance_work_mem روی بخش بزرگی از حافظه دستگاه تنظیم شده است، در حالی که shared_buffers و برنامه شما نیز به حافظه نیاز دارند؛ این وضعیت منجر به فعال شدن OOM killer هسته سیستم‌عامل می‌شود. دستور sudo dmesg -T | grep -i 'killed process' خطی که نام postgres را ذکر کرده است، نشان می‌دهد. این تنظیم را کاهش دهید یا ایندکس را روی یک پلن بزرگ‌تر بسازید و سپس dump را بازیابی کنید.

انتخاب گزینه مناسب

اگر در حال حاضر از Postgres استفاده می‌کنید و تعداد بردارهای شما کمتر از چند میلیون است، از pgvector استفاده کنید. ایندکس در کنار داده‌ها قرار می‌گیرد، فیلترینگ با یک عبارت WHERE انجام می‌شود و نسخه پشتیبان فعلی شما آن را پوشش می‌دهد. اگر حجم ایندکس به اندازه‌ای است که نیاز به سقف حافظه اختصاصی دارد، یا به فیلترینگ سنگین روی payload نیاز دارید، Qdrant را در کنار آن اجرا کنید و مدیریت یک سرویس دوم را بپذیرید.

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

FAQ

آیا به یک پایگاه‌داده برداری اختصاصی نیاز دارم یا Postgres کافی است؟

اگر داده‌های شما هم‌اکنون در Postgres قرار دارند، pgvector برای مدت بسیار طولانی‌تری نسبت به آنچه اکثر مقایسه‌ها پیشنهاد می‌دهند، کافی است. این افزونه بردارها را در یک ستون معمولی ذخیره می‌کند، بنابراین جستجوی فیلترشده یک عبارت WHERE است و پشتیبان‌گیری فعلی شما، ایندکس را نیز پوشش می‌دهد. زمانی به یک ذخیره‌ساز اختصاصی مانند Qdrant مهاجرت کنید که بار کاری برداری نیاز به سقف حافظه (memory ceiling) مجزا داشته باشد، یا زمانی که به فیلترینگ payload و کوانتیزاسیونی نیاز دارید که pgvector ارائه نمی‌دهد.

یک VPS چند بردار را می‌تواند در خود جای دهد؟

به‌جای حدس زدن، با استفاده از number_of_vectors * dimensions * 4 bytes * 1.5 محاسبه کنید. یک میلیون بردار 768 بعدی تقریباً 4.3 گیگابایت فضا اشغال می‌کنند، بنابراین یک پلن 8 گیگابایتی آن را به همراه فضای کافی برای Postgres در خود جای می‌دهد. یک میلیون بردار 3072 بعدی تقریباً 17 گیگابایت فضا اشغال می‌کنند و به پلن بسیار بزرگ‌تری نیاز دارند. عددی که بیشترین تأثیر را در این محاسبات دارد، ابعاد مدل embedding شماست، بنابراین آن مدل را با در نظر گرفتن هزینه‌های حافظه انتخاب کنید.

چرا با وجود اینکه ایندکس روی همان ماشین است، جستجوی برداری من کند است؟

وقتی همه چیز روی یک دستگاه است، شبکه مشکل شما نیست؛ پس به دو عامل اصلی توجه کنید. نخست، زمان فراخوانی embedding را به‌صورت جداگانه اندازه‌گیری کنید، زیرا تولید بردار پرس‌وجو (query vector) روی CPU اغلب بسیار طولانی‌تر از خودِ عملیات جستجو است. دوم، بررسی کنید که آیا ایندکس در RAM قرار دارد یا خیر. جستجوی HNSW به‌صورت تصادفی در گراف حرکت می‌کند، بنابراین به‌محض اینکه گراف به دیسک منتقل شود (spill)، هر پرش می‌تواند به یک عملیات خواندن از دیسک تبدیل شود. راهنمای رسمی Qdrant نیز تأکید دارد که نصف کردن بردارهای موجود در RAM، تقریباً تأخیر جستجو را دو برابر می‌کند.

آیا اصلاً باید یک ایندکس HNSW بسازم؟

برای کمتر از حدود صد هزار بردار، خیر. یک اسکن کامل (exhaustive scan) به ازای هر پرس‌وجو n * d * 4 بایت را می‌خواند که برای 100,000 بردار 768 بعدی، معادل 307 مگابایت است. یک CPU مدرن این حجم را در چند ده میلی‌ثانیه با دقت کامل (perfect recall) و بدون نیاز به مرحله ساخت ایندکس پردازش می‌کند. ابتدا اسکن را روی سخت‌افزار خود اندازه‌گیری کنید. زمانی ایندکس بسازید که زمان اسکن اندازه‌گیری‌شده واقعاً بیش از حد کند باشد، نه صرفاً به این دلیل که یک مقاله بنچمارک چنین توصیه‌ای کرده است.

افزایش m واقعاً چه هزینه‌ای برای من دارد؟

هزینه آن در زمان ساخت و زمان درج (insert) است، که بسیار بیشتر از حافظه است. در ابعاد 768، تغییر از مقدار پیش‌فرض m = 16 به m = 64، به ازای هر بردار 512 بایت به لینک‌های گراف اضافه می‌کند (در مقابل 3072 بایت داده برداری)، بنابراین کل حافظه حدود 12 درصد افزایش می‌یابد. با این حال، هر عملیات درج باید چهار برابر بیشتر همسایه پیدا کرده و لینک کند. ابتدا ef_search را تنظیم کنید، زیرا تغییر آن هزینه‌ای ندارد و نیازی به بازسازی ایندکس (rebuild) نخواهد داشت.

#vector-database#rag#pgvector#qdrant#self-hosting