SSD Nodes Learn 🎉 VPS від $5.50/міс
Посібники Matt ConnorВід Matt Connor

Як побудувати RAG-конвеєр на власному VPS

Зберіть RAG на одному VPS: схема PostgreSQL із pgvector, розрахунок HNSW, локальна embedding-модель Ollama та SQL для перевірки пошуку.

Як виглядає self-hosted RAG-конвеєр

RAG-конвеєр (retrieval augmented generation) має п’ять етапів: розбити документи на фрагменти, створити embedding-вектори для фрагментів, зберегти вектори, знайти найближчі до запитання фрагменти та передати їх мовній моделі, яка сформує відповідь. На VPS, який ви вже орендуєте, перші чотири етапи виконуються на цьому сервері. PostgreSQL із розширенням pgvector зберігає вектори, а невелика embedding-модель, яку обслуговує Ollama, перетворює текст на вектори. Лише останній етап має виконуватися за межами сервера.

Саме на такому розподілі побудовано цей посібник. Розбиття на фрагменти — це звичайне CPU-навантаження. Embedding виконує модель зі 137 мільйонами параметрів, якій потрібно кількасот мегабайт RAM. Сховище — це таблиця Postgres, розмір якої можна обчислити заздалегідь, ще до додавання першого рядка. Для корпусу зі сотнями тисяч фрагментів усі ці етапи виконуються на звичайному VPS. Генерація відрізняється, оскільки кожне запитання створює витрати, які повторюються постійно.

Які частини RAG-конвеєра насправді коштують грошей

У наскрізному посібнику з RAG від DigitalOcean використовуються керована векторна база даних і розміщена модель для створення embedding-векторів, а розділ про витрати містить загальні рекомендації: кешувати повторювані запити, зменшувати кількість отриманих фрагментів і виконувати reranking перед генерацією. Ці рекомендації правильні. Але в ньому не розглянуто варіант, який змінює розрахунок: запускати модель для створення embedding-векторів на сервері, за який ви вже платите.

Рахуйте токени, а не долари, оскільки кількість токенів не застаріває після зміни прайс-листа. Візьмемо корпус зі 100,000 фрагментів по 400 токенів кожен, 10,000 запитів до нього, 8 фрагментів, які передаються моделі для кожної відповіді, запит і блок інструкцій обсягом 100 токенів та відповіді обсягом 400 токенів.

ChartToken load for a 100,000 chunk corpus and 10,000 questions
The data behind this chart
[
  {
    "label": "Embed the corpus (once)",
    "tokens_millions": 40,
    "tokens_per_question": "4,000"
  },
  {
    "label": "Embed each question",
    "tokens_millions": 0.2,
    "tokens_per_question": "20"
  },
  {
    "label": "Generation input",
    "tokens_millions": 33,
    "tokens_per_question": "3,300"
  },
  {
    "label": "Generation output",
    "tokens_millions": 4,
    "tokens_per_question": "400"
  }
]

Створення embedding-векторів для всього корпусу — це 40 мільйона токенів, і воно виконується один раз. Якщо розподілити цей обсяг на 10,000 запитів, припадає 4,000 токенів на запит. Для ста тисяч запитів цей показник зменшується до 400. Обсяг генерації ніколи не зменшується. Для кожного запиту, на який ви коли-небудь відповісте, це 3,300 вхідних токенів і 400 вихідних токенів.

Отже, витрати визначає етап, який повторюється. Виконуйте створення embedding-векторів самостійно, оскільки платити за нього потрібно один раз, а VPS все одно працює. Оплачуйте генерацію, оскільки саме на цьому етапі краща модель виправдовує реальні витрати. Кешування важливе з тієї самої причини: влучання в кеш пропускає єдиний етап, витрати якого ніколи не амортизуються. Різниця між KV-кешем і кешем промптів визначає, яку половину цього процесу можна повторно використати. Промпт RAG містить стабільний блок інструкцій, за яким іде змінний блок фрагментів. Саме така структура найбільше виграє від кешування.

Chunking: чому фіксований розмір із перекриттям є правильним стандартним варіантом

Chunk — це одиниця, яку ви отримуєте під час пошуку, тому його розмір визначає всі подальші результати. Chunk має бути достатньо малим, щоб його embedding стосувався приблизно однієї теми. Embedding — це одна точка у просторі, тому chunk, що охоплює чотири теми, опиняється між ними й не є близьким до жодної з них. Водночас chunk має бути достатньо великим, щоб самостійно відповісти на запитання. Language model бачить chunk, а не документ навколо нього.

Почніть із 300 слів із перекриттям у 50 слів. В англійській мові на одне слово припадає приблизно 1.3 token, тому 300 слів — це близько 400 token. Перекриття потрібне тому, що речення на межі інакше буде розділено навпіл, і жодна з половин не дасть відповіді на запитання.

Спочатку розділяйте документи за структурою, якщо вони її мають. Розділяйте за заголовками, потім за абзацами, а правило фіксованого розміру застосовуйте лише всередині секції, яка все ще є надто довгою. Chunk, що починається всередині речення, погано читається у фінальній відповіді, оскільки model цитує наданий йому текст.

Не налаштовуйте chunking, доки не зможете його вимірювати. Фіксований розмір із перекриттям дає детермінований результат і не потребує значних ресурсів для повторного запуску, тому це базовий варіант, результати якого можна покращувати. Спочатку створіть scoring query нижче в документі, а потім змінюйте лише один параметр за раз.

Вбудовування на тому самому сервері: витрати RAM і затримка

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

nomic-embed-text має 137 million parameters і завантаження розміром 274 MB станом на August 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. Тип стовпця має точно відповідати цьому числу.

У цій моделі є два параметри, які часто спричиняють помилки.

Префікс завдання є обов’язковим. У model card Nomic зазначено, що вхідні дані "must include a task instruction prefix". Документи вбудовуються з search_document: на початку, а запитання — з search_query: . Якщо їх пропустити, помилки не буде: ви отримаєте вектори, якість пошуку знизиться, але жоден рядок журналу не пояснить чому.

Довгий вхідний текст тихо обрізається. Endpoint /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 round trip і пошук моделі виконуються один раз, а не 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 виводить resident size завантаженої моделі. Це обсяг RAM, зарезервований на весь час, поки keep_alive утримує модель у пам’яті. Значення time, поділене на розмір пакета, дає кількість секунд на один фрагмент. Помножте його на кількість фрагментів, щоб отримати загальну одноразову вартість індексації. За плану лише з CPU розраховуйте, що корпус зі 100,000 фрагментів оброблятиметься годинами, а не хвилинами. Це нормально, оскільки операція виконується один раз і може працювати вночі під керуванням nice -n 19. Якщо кілька годин неприйнятні, справжнє питання полягає в тому, чи окупить себе оренда GPU. Це розрахунок точки беззбитковості порівняно з API tokens, а не питання особистих уподобань.

Якщо сервер уже обслуговує chat model, embedding model буде другою моделлю, що постійно перебуває в пам’яті, і обсяг RAM сумується. У матеріалі Запуск Ollama на VPS описано визначення розміру ресурсів для generation side, а в матеріалі що відбувається із self-hosted model під час одночасних запитів користувачів — поведінку системи, коли кілька людей надсилають запити одночасно. Embedding model достатньо мала, щоб працювати поруч із будь-якою з них.

Скрипт індексації від початку до кінця

В Ubuntu 24.04 звичайний pip install поза virtual environment завершується з 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() — це ваша частина: будь-який код, який обходить файли або рядки, повертаючи ідентифікатор документа та його текст. Усе інше — це pipeline.

Схема pgvector і обсяг даних

Ubuntu 24.04 постачається з postgresql-16-pgvector версії 0.6.0. Це старіше за тип halfvec. Для актуальної збірки використовуйте власний репозиторій проєкту PostgreSQL.

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

Число в назві пакета має відповідати основній версії вашого сервера. Потім створіть роль, базу даних і розширення.

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 MiB векторних даних. Для того самого набору даних, обробленого hosted-моделлю з розмірністю 1536, потрібно 587 MiB, а індекс для нього збільшується пропорційно. Половинна точність зменшує обидва значення вдвічі: halfvec(768) зберігає цей набір даних у 147 MiB. Чи впливає це на 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 та обліковими записами користувачів, self-hosted стек Supabase — це PostgreSQL із уже ввімкненим pgvector. Усі запити з цього посібника працюють там без змін.

Індексування: важливі параметри HNSW

Для таблиць із кількома тисячами рядків індекс можна не створювати. Точний пошук читає кожен рядок, але для такого обсягу це достатньо швидко, а повнота результатів є максимальною. Додайте індекс, коли послідовне сканування стане недостатньо швидким, і врахуйте компроміс: приблизний індекс повертає приблизно правильних сусідів.

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

Як визначити, чи достатньо якісно працює retrieval?

Це етап, який пропускає майже кожен посібник із RAG. Лише він показує, чи допомогли інші рішення. Для цього не потрібен evaluation framework. Потрібні 30 запитань та id чанка, який відповідає на кожне з них.

Складіть їх вручну. Візьміть запитання, які справді ставлять щодо цього корпусу, виконайте кожен запит, перегляньте результат і запишіть id чанка, який мав бути першим. Тридцять запитань не покажуть невеликі відмінності. Але вони виявлять важливі відмінності, оскільки саме вони зазвичай значні.

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

Створіть embedding для кожного запитання з префіксом 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, поділеного на позицію правильного чанка, і зараховує промах як нуль. Тому ця метрика заохочує ставити відповідь на перше місце, а не на восьме. Обидва показники змінюються, якщо змінити розмір чанка, замінити embedding model або додати keyword search. Тепер ви бачите напрямок цих змін.

Найбільше контролюйте recall at 10, оскільки генератор не може використати чанк, якого не отримав. Якщо recall at 10 дорівнює 0.9, але відповіді все одно неправильні, проблема в prompt або моделі, а не в retrieval. Такий поділ заощаджує дні безрезультатних припущень.

Перевіряйте індекс окремо. Approximate search знижує recall, і pgvector дає змогу виміряти це: виконайте той самий запит з exact search і порівняйте id.

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

Дев’ять спільних id із десяти означають, що ef_search налаштовано правильно. Чотири з десяти — збільшуйте його.

Реранжування та генерація: де API виправдовує свою вартість

Реранжувальник — це інший тип моделі. Він одночасно обробляє запит і один фрагмент та оцінює цю пару. Це точніше, ніж порівнювати два embedding-вектори, обчислені незалежно один від одного. Водночас реранжувальник надто повільний для обробки всього корпусу. Саме тому його застосовують на цьому етапі. Він отримує 40 кандидатів, повернутих пошуком, а не 100,000 фрагментів у таблиці. Тому hosted API для реранжування стягує плату за 40 коротких пар для кожного запиту та відкидає найгірші хибнопозитивні результати до переходу на дорогий етап.

Генерація створює регулярні витрати, і на них впливають два чинники. Передавайте менше фрагментів. Використовуйте recall at 10, щоб визначити мінімальну кількість фрагментів, яку можна передати без втрати відповідей. Зберігайте початок промпту стабільним byte for byte, щоб prompt cache провайдера міг його використати, а отримані фрагменти розміщуйте після цієї стабільної частини. Також кешуйте готові відповіді за запитом, оскільки найдешевший згенерований токен — це токен, який ви згенерували минулого тижня.

Розмір сервера та коли цього вже недостатньо

Кожне правило розрахунку тут ґрунтується на вимірюваннях, а не на оцінках.

  • RAM є обмежувальним ресурсом: резидентний розмір моделі з ollama ps, розмір індексу HNSW і shared_buffers, а також запас для з’єднань і page cache.
  • На диску потрібно мати подвоєний обсяг pg_total_relation_size('chunks'), оскільки під час перебудови індексу одночасно зберігаються обидві копії.
  • CPU визначає тривалість переіндексації: виміряна кількість секунд на chunk, помножена на кількість chunk.
  • Переіндексація відбувається частіше, ніж можна очікувати, оскільки зміна embedding model робить недійсними всі вже збережені вектори.

Ця схема перестає бути достатньою в момент, який можна заздалегідь визначити. Коли індекс HNSW більше не вміщується в доступний обсяг RAM, затримка запитів перетворюється на очікування операцій читання з диска, і жодне налаштування це не виправить. Коли одна таблиця обслуговує багатьох tenant, а кожен запит фільтрує дані за tenant, потрібно розділити таблицю на partition, і це потребує значної роботи. Коли операції запису під час індексації конкурують із запитами користувачів за ресурси того самого сервера, спочатку перенесіть embedding worker на другий сервер, а не базу даних. Поки не виникла одна з цих ситуацій, Postgres із pgvector на VPS, який ви вже орендуєте, є придатним production-рішенням, а наведені вище числа показують, наскільки ви близькі до обмеження.

FAQ

Чи можна запустити RAG-конвеєр на одному VPS, чи потрібна векторна база даних?

Одного VPS достатньо для корпусів обсягом у сотні тисяч фрагментів. За розмірності 768 для 100,000 фрагментів потрібно 294 MiB векторних даних, не враховуючи текст і HNSW-індекс. Це поміщається в оперативній пам’яті звичайного тарифного плану. Обмеженням є обсяг пам’яті, а не кількість рядків, оскільки пошук HNSW переходить між різними ділянками індексу. Коли індекс перестає повністю вміщатися в оперативній пам’яті, затримка зростає. Порівняйте pg_relation_size для індексу з free -m, і ви зрозумієте поточний стан.

Чи потрібен GPU для створення embeddings моїх документів?

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

Чому мій векторний запит використовує послідовне сканування замість індексу HNSW?

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

Як визначити, чи якісно працює моє отримання даних?

Створіть еталонний набір із 30 запитань. Для кожного запитання вкажіть ідентифікатор фрагмента, який містить відповідь, і збережіть поруч embeddings запитань. Потім виміряйте recall@10 — частоту появи правильного фрагмента в перших 10 результатах — і MRR, який винагороджує розміщення правильного фрагмента на першій позиції. Саме ці два показники дають змогу визначити, чи покращила зміна розміру фрагментів, моделі embeddings або об’єднання результатів за рангом. Без них ви змінюєте налаштування та покладаєтеся на власне враження від кількох відповідей.