RAG на Postgres и pgvector: пошагово, без танцев с Pinecone
Когда я первый раз делал RAG для рабочей задачи, я честно повёлся на хайп: завёл Pinecone, написал склейки с OpenAI, прикрутил LangChain. Через две недели полез отлаживать, понял, что половину кода не понимаю, и переписал всё на Postgres с pgvector. Получилось проще, дешевле и легче поддерживать. С тех пор любую задачу "бот по нашей базе знаний" я начинаю с pgvector.
Расскажу, как собрать рабочий RAG: разметить документы, посчитать эмбеддинги, сложить в Postgres, искать похожие куски и подсовывать их в LLM. Без фреймворков, на чистом SQL и Node.
Почему именно Postgres
RAG ничего особенного от хранилища не требует: положи вектор, найди ближайшие. Postgres с расширением pgvector это умеет, и при этом ты получаешь обычную базу: транзакции, бэкапы, миграции, JOIN-ы с твоими бизнес-данными. Не нужно учить новый язык запросов, не нужен ещё один сервис в инфраструктуре, не нужно платить за внешний векторный сервис.
На моём проде у нас 800 тысяч кусков документов, поиск отдаёт топ-10 за 30–80 миллисекунд. Этого хватает с большим запасом.
Включаем pgvector
CREATE EXTENSION IF NOT EXISTS vector;В управляемых Postgres (Yandex Cloud, Supabase, Neon, AWS RDS) расширение обычно уже доступно. На своём сервере — поставь пакет postgresql-17-pgvector и перезапусти.
Дальше создаём таблицу под куски (chunks):
CREATE TABLE chunks (
id BIGSERIAL PRIMARY KEY,
source_id TEXT NOT NULL,
source_type TEXT NOT NULL,
title TEXT,
content TEXT NOT NULL,
embedding VECTOR(1536) NOT NULL,
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW()
);Размерность VECTOR(1536) — под text-embedding-3-small от OpenAI. Если планируешь свою модель — поставь её размерность. Изменить размерность позже — больно, лучше сразу выбрать.
Индекс под поиск:
CREATE INDEX ON chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);HNSW — алгоритм приближённого поиска. Точнее ivfflat, требует чуть больше памяти, но в 2026 это стандарт. Параметр m — "степень" графа, ef_construction — сколько кандидатов перебирает при построении. Дефолтные значения нормальные. vector_cosine_ops — операторный класс под косинусное расстояние, оно для эмбеддингов почти всегда лучшая мера.
Чанкуем документы
Главная ошибка новичка — взять документ целиком и посчитать на него один эмбеддинг. Эмбеддинг "усреднится" по всему тексту, и для конкретного вопроса найдётся плохо. Нужно резать на куски примерно по 300–500 токенов с перекрытием в 10–20%.
Простой чанкер на Node:
function chunkText(
text: string,
chunkSize = 1500, // символов, не токенов
overlap = 200,
) {
const paragraphs = text.split(/\n{2,}/);
const chunks: string[] = [];
let current = "";
for (const p of paragraphs) {
if (current.length + p.length + 2 <= chunkSize) {
current += (current ? "\n\n" : "") + p;
} else {
if (current) chunks.push(current);
// перекрытие — возьмём хвост предыдущего
const tail = current.slice(-overlap);
current = tail + (tail ? "\n\n" : "") + p;
}
}
if (current) chunks.push(current);
return chunks;
}На длинных документах я обычно делаю двухуровневую разметку: сначала по разделам (по h2 в Markdown или явным разделителям), внутри каждого раздела — по чанкам. Так ты не разрезаешь на куски посреди важного абзаца.
Считаем эмбеддинги
Пример с OpenAI, batch-режимом — сильно дешевле и быстрее, чем по одному:
import OpenAI from "openai";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
async function embedBatch(texts: string[]) {
// OpenAI принимает до 2048 строк в одном запросе
const res = await openai.embeddings.create({
model: "text-embedding-3-small",
input: texts,
});
return res.data.map((d) => d.embedding); // number[][]
}Кладём в базу — vector сериализуется как массив в формате '[0.1, 0.2, ...]'. Через библиотеку postgres или pg я обычно передаю строкой, но проще взять pgvector-обёртку:
import postgres from "postgres";
import pgvector from "pgvector/pg";
const sql = postgres(process.env.DATABASE_URL!);
await pgvector.registerType(sql);
async function insertChunks(
sourceId: string,
sourceType: string,
title: string,
chunks: string[],
embeddings: number[][],
) {
for (let i = 0; i < chunks.length; i++) {
await sql`
INSERT INTO chunks (source_id, source_type, title, content, embedding)
VALUES (${sourceId}, ${sourceType}, ${title}, ${chunks[i]},
${pgvector.toSql(embeddings[i])})
`;
}
}Если документов много — заливай батчами по 100–500 строк через COPY или массив объектов. Один insert на чанк работает, но очень медленно при объёмах от десятка тысяч.
Поиск по запросу
Считаем эмбеддинг запроса той же моделью, ищем топ-N ближайших по косинусному расстоянию:
async function search(query: string, limit = 8) {
const [{ embedding: q }] = (await openai.embeddings.create({
model: "text-embedding-3-small",
input: query,
})).data;
const rows = await sql`
SELECT id, title, content,
1 - (embedding <=> ${pgvector.toSql(q)}) AS score
FROM chunks
ORDER BY embedding <=> ${pgvector.toSql(q)}
LIMIT ${limit}
`;
return rows;
}Оператор <=> — косинусное расстояние (0 — идентичны, 2 — противоположны). 1 - (a <=> b) — косинусная похожесть от -1 до 1, удобнее показывать в UI.
На моих данных я обычно отбрасываю всё, что ниже 0.4 score — это уже шум, ответы на нерелевантных кусках только сбивают LLM.
Гибридный поиск: вектор + полнотекст
Векторный поиск отлично ловит смысл, но плохо ловит точные термины. Если пользователь спросил "ошибка ECONNRESET" — векторно ты найдёшь "проблемы с соединением", но не сам код ошибки. Гибридный поиск решает.
ALTER TABLE chunks
ADD COLUMN tsv tsvector
GENERATED ALWAYS AS (to_tsvector('russian', content)) STORED;
CREATE INDEX chunks_tsv_idx ON chunks USING gin(tsv);Запрос с двумя сортировками по Reciprocal Rank Fusion:
WITH vec AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> $1) AS rank
FROM chunks
ORDER BY embedding <=> $1
LIMIT 50
),
fts AS (
SELECT id,
ROW_NUMBER() OVER (ORDER BY ts_rank(tsv, plainto_tsquery('russian', $2)) DESC) AS rank
FROM chunks
WHERE tsv @@ plainto_tsquery('russian', $2)
LIMIT 50
)
SELECT chunks.id, chunks.title, chunks.content,
COALESCE(1.0 / (60 + vec.rank), 0) +
COALESCE(1.0 / (60 + fts.rank), 0) AS score
FROM chunks
LEFT JOIN vec ON vec.id = chunks.id
LEFT JOIN fts ON fts.id = chunks.id
WHERE vec.id IS NOT NULL OR fts.id IS NOT NULL
ORDER BY score DESC
LIMIT 10;Параметр 60 — стандартное "k" в RRF, можно крутить от 30 до 100. На моих данных гибрид даёт +5–10 процентных пунктов к точности по сравнению с чистым векторным поиском.
Скармливаем результат LLM
Базовая сборка ответа:
const docs = await search(userQuery, 8);
const context = docs
.map((d, i) => `[${i + 1}] ${d.title}\n${d.content}`)
.join("\n\n---\n\n");
const answer = await callLLM({
system: "Отвечай только на основе [контекст]. Если в контексте нет ответа — скажи \"не нашёл\". Цитируй номер источника в квадратных скобках.",
user: `[контекст]\n${context}\n\n[вопрос] ${userQuery}`,
});Я в системный промпт всегда явно пишу "если не нашёл — скажи, что не нашёл". Иначе модель додумает ответ на нерелевантных кусках, и пользователь получит уверенную ложь.
Где RAG обычно ломается
Чанки слишком большие. Если кусок на 5000 символов, эмбеддинг будет про "вообще про что-то". Помельче и с перекрытием — лучше.
Чанки слишком маленькие. Если кусок — одно предложение, теряется контекст. Бот будет рапортовать кусками без смысла.
Не отрезаешь шум по score. Когда пользователь спрашивает что-то совсем не из базы, поиск всё равно вернёт топ-N. LLM на этих кусках сочинит. Ставь порог.
Не обновляешь индекс. Документы поменялись — эмбеддинги старые. На моих проектах я делаю отдельный воркер: при изменении строки в основной таблице — пересчитать эмбеддинги по соответствующим chunks.
Гонишь весь документ в LLM. Если кусков много, контекст распухает. Я держу cap: топ-8 чанков по 1500 символов = ~12000 символов = ~3000 токенов. Этого хватает на любой адекватный ответ.
Когда стоит уйти с pgvector
Если у тебя >10 миллионов чанков и поиск стал тормозить даже на HNSW — есть смысл смотреть в сторону Qdrant или Weaviate. До 5 миллионов pgvector с правильно настроенным индексом тянет спокойно. У меня в проде 800 тысяч на железе с 16 ГБ RAM — никаких проблем.
Если у тебя сложная фильтрация ("найди по эмбеддингу, но только в чанках с тегом X и от пользователя Y") — это обычный SQL WHERE, и тут pgvector в плюсе. В выделенных векторных БД фильтрация часто прикручена сбоку и работает медленнее, чем нативный B-tree индекс Postgres.
Совет на старте: не усложняй. Postgres + pgvector + честная нарезка на чанки + одна модель эмбеддингов. Этого достаточно для 90% задач, и переключаться на что-то другое имеет смысл, только когда упёрся в реальный потолок.