Запуск векторной базы данных на собственном VPS
Узнайте, как разместить векторную БД на одном сервере с приложением. Сравнение pgvector, Qdrant и Chroma, расчет объема RAM и влияние на задержку при поиске векторов.
Во что на самом деле обходится векторная база данных на VPS
Запуск векторной базы данных на VPS (виртуальном выделенном сервере) устраняет проблему, решение которой продают все провайдеры управляемых сервисов. Ваше приложение и индекс находятся на одной машине, поэтому поисковый запрос проходит через loopback-сокет, а не через сеть. Остаются расходы, которые всегда были основными: преобразование текста в векторы. За этим стоят еще два фактора: время на построение индекса и объем оперативной памяти, который индекс занимает в процессе работы.
Это меняет приоритеты при принятии решений. Регион и время кругового обхода (round trip) до конечной точки перестают быть вашей заботой. Вашей заботой становится произведение количества векторов, их размерности и 4 байт, так как именно оно определяет, поместится ли индекс в объем памяти, который вы арендуете ежемесячно.
Куда на самом деле уходят миллисекунды на одном узле
Проследим путь одного запроса на сходство через стек, размещенный на собственном сервере.
- Текст запроса преобразуется в вектор с помощью модели эмбеддингов. На CPU это занимает от десятков до сотен миллисекунд для короткой строки. На GPU — единицы миллисекунд.
- Вектор отправляется в хранилище. При передаче через loopback TCP или Unix domain socket это занимает доли миллисекунды.
- Хранилище обходит индекс и возвращает ближайшие строки.
- Ваш код считывает найденный текст и формирует промпт.
Шаг 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, значит, настройка индекса — неверная задача: ваша задержка вызвана моделью эмбеддингов. Запуск модели эмбеддингов локально с помощью Ollama переносит шаг 1 на тот же CPU, где выполняются шаги 2 и 3, поэтому обе части конкурируют за одни и те же ядра и оперативную память. Цикл приема и извлечения данных, обернутый вокруг этого хранилища, описан в руководстве по RAG-конвейеру на собственном сервере. RAG — это генерация с дополнением извлеченными данными: вы ищете по своим документам и вставляете лучшие совпадения в промпт.
Сканирование всех векторов при объеме до ста тысяч
Исчерпывающее сканирование сравнивает запрос с каждым сохраненным вектором. Полнота (recall) в этом случае идеальна по определению. Индекс и этап построения не требуются, а данные всегда остаются актуальными.
Арифметика показывает, когда этот метод перестает быть эффективным. Сканирование считывает 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]]Обе стороны приводятся к единичной длине, поэтому скалярное произведение является косинусным сходством, и более высокий результат означает более точное совпадение. Загружайте mat один раз при запуске, а не при каждом запросе, чтобы чтение из SQLite полностью исключалось из критического пути.
Замерьте время на своей машине, прежде чем отказываться от этого подхода.
import time
t = time.perf_counter()
search(q)
print(f"{(time.perf_counter() - t) * 1000:.1f} ms")Честные ограничения: это один процесс, удерживающий всю матрицу в оперативной памяти; он не поддерживает фильтрацию по метаданным и не рассчитан на параллельную запись. Если одна из этих причин вызывает у вас неудобства, переходите на другие решения. Сама по себе SQLite — это серьезное серверное хранилище, что подробно описано в руководстве по использованию SQLite в продакшене. Если ваша реальная рабочая нагрузка заключается в сканировании столбцов, а не в выдаче строк, то сравнение 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;'По состоянию на август 2026 года в этом пакете содержится версия pgvector 0.6.0, которая значительно отстает от актуальной версии разработчиков. В частности, для итеративного сканирования индексов требуется версия 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 (евклидово), а <#> — отрицательное скалярное произведение. Используйте тот вариант, на котором обучалась ваша модель эмбеддингов. Если выбрать неверный тип, ошибок не возникнет, но качество результатов незаметно снизится.
Без индекса такой запрос выполняет точный поиск по каждой строке. Это аналог метода «грубой силы» в Postgres, который обеспечивает такую же идеальную полноту поиска. Увеличение max_parallel_workers_per_gather позволяет задействовать больше ядер процессора. Сделайте это в первую очередь, а индексацию отложите на потом: так у вас будет базовый показатель полноты поиска, с которым можно будет сравнивать эффективность индекса.
Qdrant: когда индекс превышает возможности базы данных
Qdrant — это специализированное векторное хранилище, написанное на Rust. Его использование оправдано, когда индекс становится настолько большим, что его построение начинает конкурировать за ресурсы с основным экземпляром Postgres, или когда требуются фильтрация полезной нагрузки и квантование, которые не поддерживаются в 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 API и панель управления по адресу /dashboard, а порт 6334 предназначен для gRPC. При размещении на публичном VPS важно учитывать два момента. Согласно документации Qdrant, сервис по умолчанию запускается «без шифрования и аутентификации», а конфигурация -p 6333:6333 из руководства по быстрому старту привязывается ко всем сетевым интерфейсам. Docker открывает этот порт в обход правил ufw, так как самостоятельно записывает правила переадресации. Привязывайте сервис к 127.0.0.1 и обязательно установите API-ключ. Экземпляр 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 — это кратчайший путь от пустого проекта до работающей демонстрации поиска.
pip install chromadb
chroma run --path /srv/chromaСервис работает на порту 8000, а chromadb.HttpClient(host="localhost", port=8000) подключается к нему. Chroma поставляется со встроенной функцией эмбеддингов по умолчанию, поэтому для первого прототипа не требуется отдельный сервер моделей.
Будьте объективны при выборе. Chroma удобен тем, что скрывает решения, о которых идет речь в этом руководстве: выбор метрики расстояния и объем оперативной памяти для хранения результатов. Это оправдано для прототипа, но не подходит для системы, из-за сбоев в которой вас будут вызывать ночью. Если ваши данные уже хранятся в Postgres, перенос их в Chroma добавляет лишний процесс и проблему синхронизации, чтобы решить задачу, которой нет у pgvector.
Сколько оперативной памяти потребуется для индекса
Начните с исходных векторов, так как это базовый объем, который невозможно уменьшить настройками.
bytes = number_of_vectors * dimensions * 4Четыре байта — это один 32-битный float на каждое измерение. В документации по планированию ресурсов Qdrant рекомендуется использовать множитель 1.5 для учета метаданных и временных сегментов, создаваемых в процессе оптимизации:
memory_size = number_of_vectors * vector_dimension * 4 bytes * 1.5Ниже приведена формула, примененная к одному миллиону векторов с размерностями, которые выдают современные модели эмбеддингов.
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 RAM, оставляя место для Postgres. Тот же корпус данных при 3072 измерениях требует 17.17 GiB, что уже не помещается.
Основной рычаг управления — это первый столбец таблицы, а не последний. Сокращение размерности вдвое уменьшает количество байтов на всех последующих этапах. Модель с 768 измерениями, которая показывает чуть худшие результаты в публичных рейтингах, часто является более рациональным инженерным выбором для VPS. Тип halfvec в pgvector позволяет хранить 16-битные float, что еще раз уменьшает объем данных вдвое, а индексация выполняется через выражение:
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, но взамен обеспечивает лучший баланс между скоростью и полнотой поиска (recall). Кроме того, HNSW можно создать на пустой таблице, в то время как для IVFFlat необходимо сначала выполнить k-means на репрезентативных данных, поэтому создание индекса на пустой таблице даст низкую полноту поиска. В новой схеме 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. Увеличение этого параметра повышает полноту поиска (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 — это обход графа. Каждый переход считывает узел, расположенный в памяти независимо от предыдущего, поэтому характер обращений близок к случайному, и механизм упреждающего чтения (read-ahead) не помогает. Пока граф находится в RAM, каждый переход — это обращение к оперативной памяти. Если граф не помещается в RAM, переход может превратиться в чтение с диска, и поиск, затрагивающий несколько сотен узлов, превращается в несколько сотен операций чтения.
В документации Qdrant это сформулировано так: «если вы храните в RAM в два раза меньше векторов, задержка поиска примерно удвоится». Учитывайте это утверждение при планировании.
Если индекс заведомо не помещается в память, любой вариант — это компромисс, на который вы должны пойти осознанно.
- Используйте memory-mapping для векторов, чтобы операционная система кэшировала «горячие» страницы, а «холодные» оставляла на диске. Для приемлемой работы под этим должен находиться быстрый накопитель NVMe.
- Применяйте квантование, сохраняя каждое измерение в виде одного байта вместо четырех. Это сокращает объем данных вектора в четыре раза при небольшом, измеримом снижении точности (recall).
- Выполняйте приведение к
halfvecв pgvector, что уменьшает объем данных вдвое с меньшими потерями точности, чем при однобайтовом квантовании. - Используйте для эмбеддингов модель меньшего размера. Это самое дешевое решение, которое часто игнорируют, так как оно требует повторного создания эмбеддингов для всего корпуса данных.
Типичные сбои и сообщения об ошибках
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. Тип столбца не соответствует модели. Вы сменили модель эмбеддингов, но не пересоздали векторы. Частичное исправление здесь невозможно, так как векторы от разных моделей несовместимы, поэтому необходимо перегенерировать каждую строку.
Запрос выполняется медленно, и 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 (out-of-memory killer) ядра. sudo dmesg -T | grep -i 'killed process' показывает строку с упоминанием postgres. Уменьшите значение настройки или выполните построение индекса на более мощном сервере и восстановите дамп.
Выбор решения
Если вы уже используете Postgres и у вас менее нескольких миллионов векторов, применяйте pgvector. Индекс располагается рядом с данными, фильтрация выполняется через предложение WHERE, а существующая система резервного копирования уже охватывает эти данные. Если индекс становится настолько большим, что требует собственного ограничения по памяти, или вам необходима сложная фильтрация полезной нагрузки, запустите Qdrant параллельно и будьте готовы к обслуживанию второго сервиса.
При объеме менее ста тысяч векторов оцените производительность полного перебора (brute-force scan), прежде чем что-либо устанавливать. Исчерпывающий поиск с идеальной полнотой и отсутствием этапа построения индекса не является компромиссом при таком размере данных. Это оптимальное решение, а переход к аппроксимированному индексу означает дополнительные затраты на настройку и нагрузку на оперативную память ради экономии миллисекунд, которые вы и так не тратили.
FAQ
Нужна ли мне специализированная векторная база данных или достаточно Postgres?
Если ваши данные уже хранятся в Postgres, расширения pgvector будет достаточно гораздо дольше, чем утверждают большинство сравнений. Оно хранит векторы в обычном столбце, поэтому поиск с фильтрацией — это предложение WHERE, а ваши существующие резервные копии охватывают и индекс. Переходите на специализированное хранилище, например Qdrant, когда векторная нагрузка требует собственного лимита памяти или когда вам нужны фильтрация полезной нагрузки и квантование, которые pgvector не предоставляет.
Сколько векторов может вместить один VPS?
Рассчитайте это, а не гадайте, используя number_of_vectors * dimensions * 4 bytes * 1.5. Один миллион векторов размерностью 768 занимает примерно 4.3 GiB, поэтому тарифный план на 8 GB вместит их с запасом для Postgres. Один миллион векторов размерностью 3072 занимает примерно 17 GiB и требует гораздо более мощного плана. Число, которое сильнее всего влияет на этот показатель, — это размерность вашей модели эмбеддингов, поэтому выбирайте модель с учетом затрат на память.
Почему мой векторный поиск работает медленно, если индекс находится на той же машине?
Сеть не является проблемой на одном узле, поэтому обратите внимание на два других фактора. Во-первых, замерьте время выполнения вызова эмбеддинга отдельно, так как генерация вектора запроса на CPU часто занимает гораздо больше времени, чем сам поиск. Во-вторых, проверьте, находится ли индекс в оперативной памяти. Поиск HNSW совершает случайные переходы по графу, поэтому, как только граф вытесняется на диск, каждый переход может превратиться в чтение с диска. Согласно рекомендациям Qdrant, сокращение объема векторов в оперативной памяти вдвое примерно удваивает задержку поиска.
Стоит ли вообще строить индекс HNSW?
Не стоит, если количество векторов меньше ста тысяч. Полное сканирование считывает n * d * 4 байт за запрос, что составляет 307 MB при 100,000 векторов размерностью 768. Современный CPU обрабатывает такой объем за десятки миллисекунд с идеальной точностью и без этапа построения индекса. Сначала замерьте время сканирования на вашем оборудовании. Стройте индекс, когда измеренное время сканирования действительно становится слишком большим, а не потому, что так сказано в статье с бенчмарками.
Во что мне на самом деле обходится увеличение m?
В время построения и время вставки, причем гораздо больше, чем в память. При размерности 768 увеличение значения с m = 16 по умолчанию до m = 64 добавляет 512 байт ссылок графа на каждый вектор при 3072 байтах данных вектора, поэтому общий объем памяти возрастает примерно на 12 процентов. Однако каждая операция вставки должна найти и связать в четыре раза больше соседей. Сначала настройте ef_search, так как изменение этого параметра ничего не стоит и не требует перестроения индекса.