Vector database на VPS: pgvector, Qdrant чи Chroma
Порівняйте pgvector, Qdrant, Chroma та brute force на одному VPS. Дізнайтеся, як розрахувати RAM за кількістю векторів і їхньою розмірністю.
Фактична вартість vector database на VPS
Запуск vector database на VPS (virtual private server) усуває проблему, яку кожен керований постачальник пропонує вирішити. Ваш застосунок та індекс розміщені на одній машині, тому пошуковий запит проходить через loopback-сокет, а не мережею. Залишаються витрати, які від самого початку були основними: перетворення тексту на вектори. Додаються ще дві витрати: час на побудову індексу та обсяг RAM, який індекс займає протягом усього часу роботи.
Це змінює пріоритети під час вибору. Регіон і затримка round trip до endpoint більше не є вашою проблемою. Натомість важливим стає добуток кількості векторів, їхньої розмірності та чотирьох байтів, оскільки саме він визначає, чи поміститься індекс у пам’ять, яку ви орендуєте щомісяця.
Де насправді витрачаються мілісекунди на одному сервері
Пройдіть один запит на пошук схожості через self-hosted стек.
- Текст запиту перетворюється на вектор embedding-моделлю. На CPU це десятки або сотні мілісекунд для короткого рядка. На GPU — однозначні мілісекунди.
- Вектор передається до сховища. Через loopback TCP або Unix domain socket це частка мілісекунди.
- Сховище проходить свій індекс і повертає найближчі рядки.
- Ваш код читає відповідний текст і формує prompt.
Крок 1 зазвичай займає найбільше часу в цьому списку. На кроці 2 зосереджуються hosted-провайдери, конкуруючи за швидкістю, але на одному сервері його тривалість майже непомітна. Не вгадуйте розподіл часу. Виміряйте обидва кінці на власному сервері.
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, налаштовувати індекс не потрібно: затримку створює embedding-модель. Запуск embedding-моделі локально за допомогою Ollama розміщує крок 1 на тому самому CPU, що й кроки 2 і 3, тому обидві частини використовують ті самі ядра та ту саму RAM. Цикл завантаження й пошуку навколо цього сховища описано в посібнику із self-hosted RAG pipeline. RAG — це retrieval augmented generation: ви шукаєте власні документи та додаєте найкращі збіги до prompt.
Для менш ніж приблизно ста тисяч векторів скануйте їх усі
Повне сканування порівнює запит із кожним збереженим вектором. Повнота пошуку за визначенням ідеальна. Індекс і етап його побудови не потрібні, а дані не можуть застаріти відносно індексу.
Арифметика показує, коли цей підхід перестає бути прийнятним. Одне сканування читає n * d * 4 байт на запит, де n — кількість векторів, а d — розмірність. Для 100,000 векторів розмірністю 768 це 307 MB на запит. Сучасний CPU послідовно обробляє такий обсяг за кілька десятків мілісекунд. Для 5 мільйонів векторів це 15 GB на запит, і це вже не запит у практичному сенсі.
Тому зберігайте вектори в 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]]Обидві сторони нормалізовано до одиничної довжини, тому скалярний добуток є косинусною схожістю, а вищий бал означає ближчий збіг. Завантажте mat один раз під час запуску, а не під час кожного запиту. Тоді читання з SQLite повністю зникає з критичного шляху.
Перед тим як відкидати цей підхід, виміряйте його на власній системі.
import time
t = time.perf_counter()
search(q)
print(f"{(time.perf_counter() - t) * 1000:.1f} ms")Обмеження очевидні: один процес утримує всю матрицю в RAM, а фільтрація за метаданими та сценарій із паралельним записом не підтримуються. Якщо саме через одне з цих обмежень підхід вам не підходить, переходьте далі. SQLite — повноцінне сховище для серверного використання. Це розглянуто в посібнику з використання SQLite у production. Якщо ваше реальне навантаження полягає в обробці стовпців, а не в обслуговуванні рядків, кориснішим буде порівняння DuckDB і SQLite.
pgvector, якщо ви вже використовуєте Postgres
Якщо у вашого застосунку вже є база даних Postgres, pgvector додає найменше нових компонентів. Це розширення, а не окремий сервіс. Вектори зберігаються у звичайній таблиці поруч із рядком, який вони описують, тому фільтрований пошук — це умова WHERE, а не друга система, яку потрібно синхронізувати.
Ubuntu 24.04 містить його в компоненті universe.
sudo apt update
sudo apt install -y postgresql-16-pgvector
sudo -u postgres psql -d yourdb -c 'CREATE EXTENSION vector;'Станом на August 2026 цей пакет містить pgvector 0.6.0, що значно відстає від upstream. Зокрема, для ітеративного сканування індексів потрібна версія 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 на основну версію вашого сервера, яку виводить sudo -u postgres psql -tAc 'SHOW server_version'. Якщо встановити пакет розширення для неправильної основної версії, CREATE EXTENSION завершиться помилкою, оскільки Postgres шукає розширення лише в каталозі share запущеної версії:
ERROR: could not open extension control file "/usr/share/postgresql/16/extension/vector.control": No such file or directoryСхема є звичайним 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;<=> — це косинусна відстань, <-> — відстань L2 (евклідова), а <#> — від’ємний внутрішній добуток. Використовуйте той варіант, для якого навчали вашу embedding model. Якщо вибрати неправильний варіант, помилки не буде, але результати непомітно погіршаться.
Без індексу цей запит виконує точний пошук у кожному рядку. Це версія brute force для Postgres, описана вище, і вона має такий самий ідеальний recall. Збільшення max_parallel_workers_per_gather дає змогу задіяти більше ядер. Спочатку зробіть це, а індекс додайте пізніше, оскільки тепер у вас буде базовий рівень recall для порівняння з індексом.
Qdrant: коли індекс стає більшим за базу даних
Qdrant — спеціалізоване векторне сховище, написане на Rust. Воно виправдане, коли індекс стає достатньо великим і ви не хочете, щоб його побудова конкурувала за ресурси з Postgres вашого застосунку, або коли потрібні фільтрація payload і квантизація, яких не надає 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 обслуговує REST (representational state transfer) API і dashboard за адресою /dashboard, а порт 6334 — gRPC. Для публічного VPS важливі дві деталі. Власна документація Qdrant зазначає, що сервіс за замовчуванням працює «без шифрування або автентифікації», а -p 6333:6333 із quickstart прив’язується до всіх інтерфейсів. Docker публікує цей порт поза правилом ufw, оскільки створює власні правила переспрямування. Прив’яжіть сервіс до 127.0.0.1 і встановіть API key. Екземпляр Qdrant, доступний за публічною IP-адресою без key, фактично відкриває ваші документи для всіх.
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"}}, неправильними є назва заголовка або key. Якщо не повертається нічого, контейнер не запущений або прив’язаний до іншої адреси. Чи відповідає конфігурація контейнера вашому серверу — це звичайне питання для stateful-сервісу, тому порівняння бази даних у Docker і на хості застосовується тут без змін.
Chroma і для чого він потрібен
Chroma — це найкоротший шлях від порожнього середовища до робочої демонстрації retrieval.
pip install chromadb
chroma run --path /srv/chromaЦей сервіс прослуховує порт 8000, а chromadb.HttpClient(host="localhost", port=8000) підключається до нього. Chroma постачається з функцією embedding за замовчуванням, тому для першого прототипу окремий сервер моделей не потрібен.
Варто чесно оцінювати компроміси. Chroma зручний, оскільки приховує рішення, про які йдеться в цьому посібнику: яку метрику відстані використовувати та який обсяг RAM займатиме результат. Для прототипу це правильно, але для системи, через яку вам доведеться реагувати на інциденти, — ні. Якщо ваші дані вже зберігаються в Postgres, перенесення їх до Chroma додає окремий процес і проблему синхронізації, щоб розв’язати проблему, якої у pgvector немає.
Скільки RAM потрібно для індексу
Почніть із необроблених векторів, оскільки вони задають мінімальний обсяг, який не можна зменшити налаштуваннями.
bytes = number_of_vectors * dimensions * 4Чотири байти — це одне 32-бітне число з плаваючою комою для кожного виміру. У документації Qdrant із планування місткості використовується множник 1.5 для метаданих і тимчасових сегментів, які створюються під час оптимізації:
memory_size = number_of_vectors * vector_dimension * 4 bytes * 1.5Ось результат застосування цієї формули до одного мільйона векторів із кількістю вимірів, яку мають реальні embedding-моделі.
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, для одного мільйона фрагментів потребує приблизно 4.29 GiB. Цього достатньо для плану на 8 GB, і ще залишиться місце для Postgres. Для того самого корпусу з 3072 вимірами потрібно 17.17 GiB, тому такого плану недостатньо.
Ключовим є перший стовпець цієї таблиці, а не останній. Зменшення кількості вимірів удвічі назавжди зменшує вдвічі кожен подальший обсяг даних. Модель із 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. Для більшої кількості вимірів індексуйте приведене до типу halfvec значення, який підтримує до 4,000 вимірів, або не створюйте індекс взагалі.
Вартість m та ef_construction під час побудови
HNSW (hierarchical navigable small world) — це індекс, до якого звертаються і pgvector, і Qdrant. Це багаторівневий граф. Кожен вектор є вузлом із посиланнями на близькі вузли. Під час пошуку алгоритм переходить цими посиланнями до запиту, а не читає всі дані.
m — це кількість посилань, які зберігає кожен вузол. У документації Faiss наведено оцінку пам’яті HNSW: (d * 4 + m * 2 * 4) байт на вектор. Також рекомендовано підтримувати m у межах від 4 до 64. Розглянемо 768 вимірів і мільйон векторів.
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 GiB до 3.34 GiB. Це приблизно 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 формує граф у пам’яті, якщо він у ній поміщається. Коли пам’яті недостатньо, 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 і запустіть побудову знову. Перевіряйте перебіг роботи з іншої сесії:
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, але натомість забезпечує кращий компроміс між швидкістю та повнотою пошуку. Крім того, HNSW можна створити для порожньої таблиці, тоді як IVFFlat спочатку має виконати k-means на репрезентативних даних. Тому побудова IVFFlat на порожній таблиці дає низьку повноту пошуку. У новій схемі HNSW можна створити заздалегідь.
ef_search: параметр, який налаштовують після побудови індексу
m і ef_construction фіксуються в індексі. ef_search — ні. Він визначає кількість кандидатів, які пошук зберігає під час обходу графа. Значення можна змінювати для окремого сеансу або запиту.
SET hnsw.ef_search = 100;
SELECT id, body FROM chunks ORDER BY embedding <=> $1 LIMIT 5;Значення за замовчуванням — 40. Якщо збільшити його, повнота пошуку зросте, але разом із нею збільшиться затримка. Якщо зменшити його, зменшаться обидва показники. Це єдиний параметр повноти пошуку, який можна змінювати без перебудови індексу. Тому налаштовуйте його на фіксованому наборі запитів, для яких правильні відповіді вже відомі, і зупиніться, коли повнота пошуку перестане зростати.
Є одна особливість. ef_search погано взаємодіє з вибірковою умовою WHERE, оскільки індекс повертає фіксовану кількість кандидатів, а фільтр застосовується після цього. Фільтр, який відхиляє більшість рядків, може залишити менше ніж LIMIT результатів, хоча відповідні рядки є в таблиці. pgvector 0.8 вирішує цю проблему за допомогою ітеративного сканування:
SET hnsw.iterative_scan = relaxed_order;Після цього індекс повторно сканується для пошуку додаткових кандидатів, доки ліміт не буде досягнуто, але не більше ніж до hnsw.max_scan_tuples. Значення за замовчуванням — 20000. strict_order зберігає точне впорядкування за відстанню, але потребує більше ресурсів. Саме цієї функції немає в пакеті Ubuntu 0.6.0. Відсутні рядки під час застосування фільтра — ознака, за якою це можна визначити.
Чому індекс має вміщуватися в RAM
Пошук HNSW — це обхід графа. Кожен перехід читає вузол, розташований у пам’яті незалежно від попереднього вузла, тому схема доступу близька до випадкової, а попереднє читання не допомагає. Поки граф перебуває в RAM, кожен перехід є зверненням до пам’яті. Якщо граф не вміщується в RAM, перехід може перетворитися на читання з диска, і пошук, який звертається до кількох сотень вузлів, спричиняє кілька сотень операцій читання.
У документації Qdrant це сформульовано так: «якщо в RAM зберігати вдвічі менше векторів, затримка пошуку приблизно подвоїться». Плануйте систему з урахуванням цього твердження.
Якщо індекс справді не вміщується, кожен варіант має компроміси, які слід обирати свідомо.
- Відобразіть вектори в пам’ять, щоб операційна система кешувала часто використовувані сторінки, а рідко використовувані залишала на диску. Для прийнятної роботи потрібне швидке сховище NVMe (non-volatile memory express).
- Застосуйте квантизацію та зберігайте кожен вимір як один байт замість чотирьох. Це зменшує обсяг векторів у чотири рази за невеликої, але вимірюваної втрати повноти результатів.
- Перетворіть вектори на
halfvecу pgvector. Це вдвічі зменшує обсяг даних і спричиняє меншу втрату повноти результатів, ніж квантизація до одного байта. - Використайте модель із меншою кількістю вимірів. Це найдешевше виправлення, але його часто не обирають, оскільки доведеться повторно створити embedding для всього корпусу.
Режими відмов і повідомлення, які ви побачите
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 і не виконали повторне створення embedding. Часткового виправлення тут немає, оскільки вектори з двох різних моделей взагалі не можна порівнювати. Тому потрібно повторно згенерувати дані для кожного рядка.
Запит виконується повільно, а EXPLAIN показує послідовне сканування. Клас операторів індексу та оператор запиту не збігаються. 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 і ваш застосунок також потребують пам’яті, процес завершується через засіб ядра, який припиняє процеси через нестачу пам’яті. sudo dmesg -T | grep -i 'killed process' показує рядок із назвою postgres. Зменште це значення або побудуйте індекс на потужнішому плані, а потім відновіть дамп.
Вибір варіанта
Якщо ви вже використовуєте Postgres і маєте менше кількох мільйонів векторів, використовуйте pgvector. Індекс зберігається поруч із даними, фільтрація виконується через умову WHERE, а наявна система резервного копіювання вже охоплює цей індекс. Якщо індекс достатньо великий і для нього потрібне окреме обмеження використання пам’яті або якщо потрібна складна фільтрація payload, розгорніть Qdrant поруч із Postgres і врахуйте, що доведеться адмініструвати ще один сервіс.
Якщо векторів приблизно менше ніж сто тисяч, спочатку виміряйте повне сканування, перш ніж щось встановлювати. Для такого обсягу повний пошук із гарантованою повнотою результатів і без етапу побудови індексу не є компромісом. Це правильний варіант. Використання approximate index означає додаткове налаштування та навантаження на RAM в обмін на мілісекунди, які раніше не витрачалися.
FAQ
Чи потрібна окрема векторна база даних, чи достатньо Postgres?
Якщо ваші дані вже зберігаються в Postgres, pgvector буде достатнім набагато довше, ніж випливає з більшості порівнянь. Він зберігає вектори у звичайному стовпці, тому пошук із фільтрацією — це умова WHERE, а наявна система резервного копіювання охоплює й індекс. Переходьте на окреме сховище, як-от Qdrant, коли векторному навантаженню потрібне власне обмеження пам’яті або коли потрібні фільтрація payload і квантизація, яких pgvector не підтримує.
Скільки векторів може зберігати один VPS?
Не вгадуйте, а розрахуйте це за допомогою number_of_vectors * dimensions * 4 bytes * 1.5. Один мільйон векторів розмірності 768 займає приблизно 4.3 GiB, тому тарифний план на 8 GB вміщує їх і залишає місце для Postgres. Один мільйон векторів розмірності 3072 займає приблизно 17 GiB і потребує значно потужнішого тарифного плану. Найбільше на цей показник впливає розмірність вашої embedding-моделі, тому обирайте модель з урахуванням витрат пам’яті.
Чому пошук векторів повільний, якщо індекс розміщено на тому самому комп’ютері?
На одному комп’ютері мережа не є проблемою, тому перевірте два інші чинники. По-перше, окремо виміряйте час виклику embedding-моделі, оскільки генерація вектора запиту на CPU часто триває значно довше, ніж сам пошук. По-друге, перевірте, чи перебуває індекс у RAM. Пошук HNSW випадково переходить між вузлами графа, тому після виходу графа за межі диска кожен перехід може перетворитися на читання з диска. Власні рекомендації Qdrant вказують, що зменшення кількості векторів у RAM удвічі приблизно подвоює затримку пошуку.
Чи потрібно взагалі створювати індекс HNSW?
Не варто робити цього, якщо векторів менше приблизно ста тисяч. Повне сканування читає n * d * 4 байт на запит. Для 100,000 векторів розмірності 768 це 307 MB, і сучасний CPU послідовно обробляє такий обсяг за десятки мілісекунд, забезпечуючи повну повноту результатів і не потребуючи етапу побудови індексу. Спочатку виміряйте час сканування на власному обладнанні. Створюйте індекс лише тоді, коли виміряний час сканування справді надто великий, а не через рекомендації у статті з бенчмарками.
Скільки насправді коштує збільшення m?
Передусім часом побудови та вставки, а не пам’яттю. Для розмірності 768 перехід від типового значення m = 16 до m = 64 додає 512 байт зв’язків графа на вектор, тоді як дані вектора займають 3072 байт. У результаті загальний обсяг пам’яті зростає приблизно на 12 відсотків. Однак під час кожної вставки потрібно знаходити та зв’язувати в чотири рази більше сусідів. Спочатку налаштуйте ef_search, оскільки його можна змінити без додаткових витрат і без повторної побудови індексу.