SSD Nodes Learn 🎉 VPS от $5.50/мес
Руководства Matt ConnorАвтор: Matt Connor

Как создать RAG-конвейер на собственном VPS

Пошаговое руководство по настройке RAG на одном сервере. Вы узнаете, как использовать PostgreSQL с расширением pgvector, настроить HNSW индексы и локальные модели через Ollama.

Как выглядит self-hosted RAG-конвейер

RAG-конвейер (retrieval augmented generation) состоит из пяти этапов: разбиение документов на фрагменты (chunking), создание эмбеддингов для этих фрагментов, хранение векторов, поиск наиболее подходящих фрагментов по запросу и отправка этих фрагментов в языковую модель для генерации ответа. На арендуемом VPS первые четыре этапа выполняются локально. PostgreSQL с расширением pgvector хранит векторы, а небольшая модель для эмбеддингов, работающая через Ollama, преобразует в них текст. Только последний этап требует внешних ресурсов.

Это разделение — основной аргумент данного руководства. Разбиение на фрагменты — это стандартная нагрузка на CPU. Эмбеддинги обрабатываются моделью со 137 миллионами параметров, которая занимает несколько сотен мегабайт оперативной памяти. Хранилище представляет собой таблицу Postgres, объем которой можно рассчитать математически еще до записи первой строки. Для корпуса из сотен тысяч фрагментов всех этих ресурсов достаточно на обычном VPS. Генерация отличается тем, что она требует затрат на каждый запрос на постоянной основе.

Какие этапы RAG-конвейера действительно требуют затрат

В руководстве по RAG от DigitalOcean предлагается использовать управляемую векторную базу данных и хостинговую модель эмбеддингов, а раздел о стоимости носит качественный характер: кэшируйте повторяющиеся запросы, ограничивайте количество извлекаемых фрагментов, используйте переранжирование перед генерацией. Эти советы верны. Однако там упущен вариант, меняющий экономику процесса: запуск модели эмбеддингов на сервере, за который вы уже платите.

Считайте токены, а не доллары, так как количество токенов не меняется при обновлении прайс-листа. Возьмем корпус из 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"
  }
]

Эмбеддинг всего корпуса составляет 40 миллионов токенов, и это разовая операция. При распределении на 10,000 вопросов это дает 4,000 токенов на вопрос. При ста тысячах вопросов это значение падает до 400. Затраты на генерацию не снижаются никогда. Они составляют 3,300 токенов на входе и 400 токенов на выходе для каждого вопроса, на который вы будете отвечать.

Таким образом, расходы следуют за повторяющимся этапом. Выполняйте этап эмбеддинга самостоятельно, так как вы платите за него один раз, а VPS и так работает. Покупайте этап генерации, так как именно здесь использование более качественной модели оправдывает затраты. Кэширование важно по той же причине: попадание в кэш позволяет пропустить единственный этап, стоимость которого не амортизируется. Разница между KV-кэшем и кэшем промптов определяет, какую часть данных можно использовать повторно, а RAG-промпт имеет стабильный блок инструкций, за которым следует меняющийся блок фрагментов — именно такая структура выигрывает от этого больше всего.

Разбиение на части: почему фиксированный размер с перекрытием — оптимальный выбор по умолчанию

Часть (chunk) — это единица данных, которую вы извлекаете, поэтому её размер определяет всё последующее. Она должна быть достаточно маленькой, чтобы её векторное представление (embedding) относилось к одной теме. Поскольку embedding — это единственная точка в пространстве, часть, охватывающая четыре темы, окажется между ними и не будет близка ни к одной из них. Она должна быть достаточно большой, чтобы самостоятельно ответить на вопрос, так как языковая модель видит только эту часть, а не весь документ целиком.

Начните с 300 слов с перекрытием в 50 слов. В английском языке примерно 1.3 токена на слово, поэтому 300 слов — это около 400 токенов. Перекрытие необходимо, так как в противном случае предложение, попавшее на границу, будет разделено пополам, и ни одна из частей не ответит на вопрос.

Сначала выполняйте разбиение по структуре, если документы её имеют. Разделяйте по заголовкам, затем по абзацам, и применяйте правило фиксированного размера только внутри секции, которая всё ещё слишком велика. Часть, начинающаяся с середины предложения, плохо читается в итоговом ответе, так как модель цитирует то, что вы ей передали.

Не настраивайте параметры разбиения, пока не сможете их измерить. Фиксированный размер с перекрытием — это детерминированный и недорогой в повторном запуске метод, что делает его базовым уровнем, который можно улучшить. Сначала создайте запрос для оценки результатов, а затем изменяйте параметры по одному за раз.

Размещение на том же сервере: затраты оперативной памяти и задержки

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

nomic-embed-text содержит 137 миллионов параметров, размер загружаемого файла составляет 274 MB по состоянию на август 2026 года. Проверьте, какие данные возвращает модель, прежде чем проектировать под них таблицу базы данных.

curl -s http://127.0.0.1:11434/api/embed \
  -d '{"model": "nomic-embed-text", "input": "search_document: hello"}' |
  python3 -c 'import json,sys; print(len(json.load(sys.stdin)["embeddings"][0]))'

Эта команда выводит 768. Тип вашего столбца должен в точности соответствовать этому числу.

Две настройки этой модели часто сбивают пользователей с толку.

Префикс задачи обязателен. В карточке модели Nomic указано, что входные данные «должны содержать префикс с инструкцией задачи». Документы индексируются с префиксом search_document: , вопросы — с search_query: . Если их пропустить, ошибки не возникнет: вы получите векторы, но качество поиска упадет, а в логах не будет ни одного сообщения о причине проблемы.

Длинные входные данные молча обрезаются. Эндпоинт /api/embed принимает поле truncate, значение по умолчанию — true, а модель в сборке Ollama заявляет контекст 2K. Фрагмент, превышающий этот лимит, обрезается по границе и всё равно векторизуется, поэтому его окончание становится недоступным для поиска. Передавайте "truncate": false во время тестирования: тогда фрагмент с превышением лимита вызовет ошибку, вместо того чтобы пройти обработку некорректно.

Используйте пакетную обработку запросов и держите модель в памяти.

curl -s http://127.0.0.1:11434/api/embed -d '{
  "model": "nomic-embed-text",
  "input": ["search_document: first chunk", "search_document: second chunk"],
  "keep_alive": "30m"
}' > /dev/null

input принимает список, и один запрос с 32 фрагментами эффективнее 32 отдельных запросов, так как HTTP-задержка и поиск модели выполняются один раз вместо 32. keep_alive определяет, как долго модель остается в памяти после запроса; значение по умолчанию — 5 минут. Когда время истекает, следующий запрос снова тратит время на загрузку модели.

Измерьте два ключевых показателя на своем оборудовании. Они зависят от количества vCPU, поэтому опубликованные цифры не будут совпадать с вашими.

ollama ps
time curl -s http://127.0.0.1:11434/api/embed \
  -d '{"model":"nomic-embed-text","input":"search_document: ... one real chunk ..."}' > /dev/null

ollama ps выводит объем оперативной памяти, занимаемый загруженной моделью; это RAM, которую вы резервируете на всё время, пока keep_alive удерживает модель. Результат time, деленный на размер пакета, дает время в секундах на один фрагмент. Умножьте это на количество фрагментов, чтобы получить разовые затраты на индексацию. На конфигурации только с CPU ожидайте, что индексация корпуса из 100,000 фрагментов займет часы, а не минуты. Это допустимо, так как процесс выполняется один раз и может работать всю ночь под управлением nice -n 19. Если время в несколько часов неприемлемо, вопрос сводится к тому, окупится ли аренда GPU — это расчет точки безубыточности в сравнении с API-токенами, а не вопрос предпочтений.

Если сервер уже обслуживает чат-модель, модель эмбеддингов станет второй резидентной моделью, и потребление RAM суммируется. В статье Запуск Ollama на VPS рассматривается подбор ресурсов для генерации, а в поведении self-hosted модели при одновременных пользователях — что происходит, когда несколько человек отправляют запросы одновременно. Модель эмбеддингов достаточно мала, чтобы работать параллельно с любой из них.

Скрипт индексации от начала до конца

В Ubuntu 24.04 обычный вызов pip install вне виртуального окружения завершается ошибкой error: externally-managed-environment, так как системный Python управляется через 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() — это ваша часть: любой код, который обходит файлы или строки и возвращает идентификатор документа и его текст. Всё остальное — это конвейер обработки.

Хранилище: схема 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

Номер в имени пакета должен соответствовать мажорной версии вашего сервера. Затем создайте роль, базу данных и расширение.

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 байт, а halfvec2 * 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 МиБ для хранения векторных данных. Тот же корпус, обработанный моделью с 1536 измерениями, потребует 587 МиБ, а индекс для него вырастет пропорционально. Использование половинной точности сокращает оба показателя вдвое: 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

Если в таблице менее нескольких тысяч строк, индекс не нужен. Точный поиск просматривает каждую строку, на таких объемах это достаточно быстро и обеспечивает идеальную полноту выборки (recall). Добавляйте индекс, когда последовательное сканирование перестает быть достаточно быстрым, и учитывайте компромисс: аппроксимированный индекс возвращает приблизительно верных соседей.

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 с оператором <=>, если вы не уверены, что ваша модель выдает векторы единичной длины, так как косинусное расстояние игнорирует длину вектора, а скалярное произведение — нет.

Следите за процессом построения. Когда граф превышает 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 в сессии, где строится индекс, и не меняйте глобальное значение по умолчанию на сервере, так как этот параметр действует на операцию обслуживания, а высокое глобальное значение может привести к нехватке памяти. Отслеживайте длительное построение из второй сессии.

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;

Более высокое значение заставляет алгоритм обходить большую часть графа, что позволяет найти лучших соседей, но увеличивает задержку. Это параметр сессии, поэтому его можно увеличить для конкретного запроса, не затрагивая сам индекс.

Если запрос вообще не использует индекс, это видно в плане выполнения.

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

Последовательное сканирование в данном случае часто связано с хранением данных. Вектор размерностью 768 занимает 3,080 байт, что превышает объем, который Postgres хранит внутри строки, поэтому значение перемещается в таблицу TOAST (хранилище для данных большого размера вне основной таблицы). В документации pgvector указано, что планировщик не учитывает данные вне основной таблицы при оценке стоимости, из-за чего последовательное сканирование может выглядеть дешевле, чем оно есть на самом деле. ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; позволяет хранить векторы внутри строки. Настройка применяется к строкам, записанным после внесения изменений, поэтому для существующих строк потребуется пересоздание таблицы.

Поиск: один запрос, два сигнала

Векторный поиск находит текст, близкий по смыслу к вопросу. Он плохо справляется с точными строками: артикулами, кодами ошибок или фамилиями. Поиск по ключевым словам работает наоборот, и 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 — это эмбеддинг вопроса, полученный с помощью той же модели и сформированный с префиксом search_query: . $2 — это текст вопроса. Оба параметра передаются из вашего приложения. websearch_to_tsquery принимает реальный вопрос пользователя, не вызывая ошибок из-за знаков препинания, в отличие от to_tsquery. Важно учитывать еще один момент: добавление фильтра WHERE поверх сканирования HNSW может вернуть меньше строк, чем вы запросили, так как сначала выполняется поиск по индексу, а затем применяется фильтр. SET hnsw.iterative_scan = relaxed_order; заставляет pgvector продолжать сканирование до тех пор, пока не будет набрано достаточное количество строк.

Как определить качество поиска?

Это этап, который пропускает почти каждое руководство по RAG, хотя именно он показывает, принесли ли пользу выбранные настройки. Для этого не нужна сложная система оценки. Достаточно 30 вопросов и идентификаторов фрагментов (chunk), которые содержат ответы на них.

Составьте их вручную. Возьмите вопросы, которые пользователи действительно задают по данному корпусу документов, выполните поиск по каждому, изучите результат и запишите id фрагмента, который должен был оказаться в выдаче. 30 вопросов не помогут выявить незначительные различия, но они покажут существенные проблемы, так как те всегда заметны.

CREATE TABLE gold (
  id        bigserial PRIMARY KEY,
  question  text   NOT NULL,
  chunk_id  bigint NOT NULL REFERENCES chunks(id),
  embedding vector(768) NOT NULL
);

Преобразуйте каждый вопрос в вектор с префиксом search_query: , сохраните их, а затем оцените весь набор одним запросом.

WITH hits AS (
  SELECT g.id,
         min(r.rank) FILTER (WHERE r.id = g.chunk_id) AS hit_rank
  FROM gold g
  CROSS JOIN LATERAL (
    SELECT top.id, row_number() OVER (ORDER BY top.distance) AS rank
    FROM (SELECT c.id, c.embedding <=> g.embedding AS distance
          FROM chunks c
          ORDER BY c.embedding <=> g.embedding
          LIMIT 10) top
  ) r
  GROUP BY g.id
)
SELECT count(*)         AS questions,
       count(hit_rank)  AS found_in_top_10,
       round(avg(coalesce(1.0 / hit_rank, 0)), 3) AS mrr
FROM hits;

found_in_top_10, деленное на questions, — это показатель recall at 10: как часто правильный ответ попадал в окно, которое вы передаете модели. MRR (mean reciprocal rank) вычисляет среднее значение 1, деленного на позицию правильного фрагмента, при этом промах считается за ноль. Таким образом, метрика поощряет вывод правильного ответа на первое место, а не на восьмое. Оба показателя меняются при изменении размера фрагмента, замене модели эмбеддингов или добавлении полнотекстового поиска, и теперь вы можете видеть, в какую сторону они изменились.

Ставьте recall at 10 выше всего остального, так как генератор не сможет использовать фрагмент, который он не получил. Если recall at 10 равен 0.9, а ответы всё равно неверны, значит, проблема в промпте или в самой модели, а не в поиске. Это простое разделение экономит дни догадок.

Проверяйте индекс отдельно. Приблизительный поиск (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

Реренкер — это модель другого типа. Она считывает вопрос и один фрагмент данных вместе, после чего присваивает этой паре оценку. Это эффективнее, чем сравнение двух эмбеддингов, вычисленных независимо, но такой метод слишком медленный для работы со всем корпусом данных. Именно поэтому он находится здесь. Модель обрабатывает 40 кандидатов, полученных на этапе поиска, а не 100,000 фрагментов из таблицы. В результате API для реранжирования взимает плату за 40 коротких пар на каждый вопрос и отсеивает наиболее вероятные ложные срабатывания до того, как они попадут на дорогостоящий этап обработки.

Генерация — это статья постоянных расходов, на которую влияют два рычага. Отправляйте меньше фрагментов: используйте метрику recall at 10, чтобы определить минимально необходимое количество, не теряя при этом полноту ответов. Сохраняйте начало промпта неизменным байт в байт, чтобы работал prompt cache провайдера, и размещайте полученные фрагменты после этой стабильной части. Также кэшируйте готовые ответы по вопросам, так как самый дешевый сгенерированный токен — это тот, который вы сгенерировали на прошлой неделе.

Выбор размера сервера и момент, когда ресурсов становится недостаточно

Любое правило выбора размера здесь основано на измерениях, а не на оценках.

  • RAM является ограничивающим фактором: размер резидентной модели из ollama ps плюс размер индекса HNSW, плюс shared_buffers, с запасом для соединений и page cache.
  • Диск требует удвоенного объема pg_total_relation_size('chunks'), так как при перестроении индекса обе копии хранятся одновременно.
  • CPU определяет время переиндексации: ваши измеренные секунды на чанк, умноженные на количество чанков.
  • Переиндексация происходит чаще, чем вы ожидаете, так как смена модели эмбеддингов делает недействительными все уже сохраненные векторы.

Эта архитектура перестает справляться в момент, который можно предвидеть заранее. Когда индекс HNSW перестает помещаться в доступную RAM, задержка запросов превращается в операции поиска на диске, и никакие настройки это не исправят. Когда одна таблица обслуживает множество клиентов и каждый запрос фильтруется по клиенту, решением становится партиционирование таблицы, а это требует серьезных усилий. Когда запись индекса и запросы пользователей конкурируют за ресурсы одного сервера, перенесите воркер эмбеддингов на второй сервер до того, как переносить базу данных. Пока ничего из этого не произошло, Postgres с pgvector на VPS, который вы уже арендуете, является готовым решением для продакшена, а приведенные выше цифры показывают, насколько далеко находится предел возможностей.

FAQ

Можно ли запустить RAG-конвейер на одном VPS или мне нужна векторная база данных?

Одного VPS достаточно для корпусов объемом в сотни тысяч чанков. При 768 измерениях 100 000 чанков занимают 294 МиБ векторных данных плюс текст и индекс HNSW, что помещается в оперативную память стандартного тарифного плана. Ограничением является объем памяти, а не количество строк, так как поиск HNSW перемещается по индексу, и задержка возрастает, как только индекс перестает помещаться в RAM. Сравните pg_relation_size по индексу с free -m, чтобы понять текущее состояние.

Нужен ли мне GPU для эмбеддинга документов?

Нет, если вы выполняете эмбеддинг один раз, а затем только делаете запросы. Модель со 137 миллионами параметров, такая как nomic-embed-text, работает на CPU, и полный проход по большому корпусу занимает часы, которые можно выделить в ночное время. GPU становится необходим, когда документы поступают непрерывно или когда вы хотите запускать генерацию на том же сервере. Замерьте время обработки одного пакета с помощью /api/embed на вашем сервере и умножьте на количество чанков, так как количество vCPU слишком сильно различается, чтобы опубликованные цифры были полезны.

Почему мой векторный запрос использует последовательное сканирование вместо индекса HNSW?

Изучите план выполнения с помощью EXPLAIN (ANALYZE, BUFFERS). Частая причина кроется в хранилище: pgvector отмечает, что планировщик не учитывает внешнее хранилище (out-of-line storage) в оценках стоимости, из-за чего последовательное сканирование выглядит дешевле, чем оно есть на самом деле. Вектор из 768 измерений занимает 3,080 байт, поэтому по умолчанию он хранится в таблице TOAST. ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; позволяет хранить новые строки внутри таблицы. Две другие причины — это оператор, который не соответствует индексу (индекс, созданный с помощью vector_cosine_ops, используется только оператором <=>), и запрос без ORDER BY ... LIMIT, так как аппроксимированный индекс работает только для упорядоченных запросов ближайших соседей.

Как понять, насколько эффективно работает поиск?

Создайте эталонный набор из 30 вопросов, каждый из которых связан с идентификатором чанка, содержащего ответ, и сохраните эмбеддинги вопросов вместе с ними. Затем измерьте показатель recall at 10 (как часто нужный чанк попадает в топ-10) и MRR (который дает более высокий балл за нахождение чанка на первом месте). Эти два числа показывают, помогло ли изменение размера чанка, модели эмбеддинга или алгоритма ранжирования. Без них вы будете менять настройки, полагаясь лишь на субъективное впечатление от нескольких ответов.