Когда в проекте появляется RAG, первым рефлексом команды становится «нужна векторная база». Появляется ещё один сервис в docker-compose, ещё одна точка отказа, ещё одна система без транзакций с основными данными. Между тем у большинства продуктов уже есть векторная база. Она называется PostgreSQL.
Ситуация из реальной команды
Команда строит поиск по базе знаний: 40 000 документов, 300 000 чанков. Под embeddings поднимают модную векторную БД. Через квартал в ретро всплывает список:
- документ удалили из продукта, а его чанки остались в векторной базе — пользователи получают ответы по несуществующей статье;
- права доступа проверяются после поиска, и топ-10 результатов иногда состоит из девяти недоступных документов и одного релевантного;
- консистентность держится на Celery-задачах синхронизации, которые иногда падают, и никто не замечает;
- бэкапы и мониторинг для нового сервиса так и не настроили.
На 300 000 чанков всё это не покупало ничего, чего не умеет pgvector. Отдельная векторная БД становится оправданной на десятках миллионов векторов, при экстремальных требованиях к latency или когда retrieval — ядро продукта. До этого масштаба вы платите операционной сложностью за производительность, которая вам не нужна.
Схема хранения чанков
create extension if not exists vector;
create table document (
id uuid primary key,
tenant_id uuid not null,
title text not null,
source_uri text,
content_hash text not null,
updated_at timestamptz not null default now()
);
create table chunk (
id uuid primary key,
document_id uuid not null references document(id) on delete cascade,
tenant_id uuid not null,
ord int not null, -- порядок в документе
text text not null,
token_count int not null,
embedding vector(1536) not null, -- размерность вашей embedding-модели
embedding_model text not null, -- без этого поля миграция моделей — ад
unique (document_id, ord)
);
create index chunk_embedding_idx on chunk
using hnsw (embedding vector_cosine_ops);
create index chunk_tenant_idx on chunk (tenant_id);Три поля, которые забывают, а потом страдают:
embedding_model— embeddings разных моделей несравнимы. Когда придёт время мигрировать (а оно придёт), без этого поля вы не сможете жить в двух мирах во время переливки.content_hashу документа — чтобы не пересчитывать embeddings неизменившихся документов при каждой синхронизации. Это прямые деньги.on delete cascade— удалённый документ забирает свои чанки с собой, транзакционно. В отдельной векторной базе это ваша ручная работа навсегда.
Поиск: фильтры и вектор в одном запросе
Главное преимущество pgvector — доступ и релевантность в одном SQL:
select c.id, c.text, c.document_id,
1 - (c.embedding <=> :query_embedding) as similarity
from chunk c
join document_acl acl
on acl.document_id = c.document_id and acl.user_id = :user_id
where c.tenant_id = :tenant_id
and c.embedding_model = :current_model
order by c.embedding <=> :query_embedding
limit 20;Права доступа применяются до ранжирования, в том же индексе, той же транзакции. Это не оптимизация — это требование безопасности из главы 22, которое здесь достаётся бесплатно.
Нюанс HNSW-индекса: это approximate-поиск, и жёсткий where может урезать кандидатов до фильтрации. На практике: если фильтр отсекает подавляющую часть строк (маленький tenant в большой таблице), проверьте recall и при необходимости поднимите hnsw.ef_search или используйте iterative scan (pgvector 0.8+). Это тот тип знания, который отличает «поставил расширение» от «эксплуатирую расширение».
Гибридный поиск тоже остаётся в одной базе: полнотекстовый tsvector-индекс рядом с векторным, объединение через reciprocal rank fusion в приложении или в SQL. Подробности ранжирования — в главе 20; здесь важно, что для этого не нужен второй движок.
Артефакты LLM-вызовов — та же база, те же правила
Таблица llm_call_log из главы 24 и таблицы пайплайнов из главы 15 живут в той же PostgreSQL. Это осознанное решение, а не лень:
- отладка «почему пользователь получил такой ответ» — это join лога вызовов с чанками, которые попали в контекст: сохраняйте в лог
chunk_idsretrieval-а; - evals из главы 38 строятся SQL-запросом по продовым вызовам;
- стоимость по клиенту/фиче/дню — обычная аналитика, без выгрузок из третьей системы.
Что предусмотреть с первого дня:
- retention policy: сырые request/response — 30–90 дней (партиционирование по месяцам решает удаление бесплатно), агрегаты стоимости — вечно;
- redaction до записи: PII вычищается на входе в лог, а не «потом допишем»;
jsonbдля payload-ов, но метрики — колонками: по jsonb удобно смотреть глазами, ноsum(cost_usd)по колонке в сто раз дешевле, чем поresponse_json->>'cost'.
Правило главы
Не заводите вторую базу данных, пока первая не сказала «не могу». PostgreSQL с pgvector даёт векторный поиск с транзакциями, ACL в том же запросе, каскадным удалением и одной системой бэкапов — а лог LLM-вызовов рядом с чанками превращает отладку и evals в обычный SQL. Отдельная векторная БД — решение для конкретного масштаба, а не атрибут «серьёзного RAG».
Чеклист главы
- Чанки хранят
tenant_id,embedding_model, порядок и связь с документом черезon delete cascade. - Права доступа применяются в самом поисковом запросе, до ранжирования.
-
content_hashпредотвращает пересчёт embeddings неизменённых документов. - Recall approximate-поиска проверен на реальном профиле фильтров,
ef_searchосознан. -
llm_call_logхранит ссылки на чанки, попавшие в контекст, — ответ можно воспроизвести. - Retention и redaction для артефактов определены до запуска, а не после первого вопроса юристов.
- Решение «нужна ли отдельная векторная БД» принято по числам (векторы, QPS, latency), а не по хайпу.
Практикум
Задача 21.1 — поиск по базе знаний на pgvector. ⭑⭑
Соберите мини-RAG-хранилище: 30–50 статей (сгенерируйте базу знаний вымышленного SaaS: биллинг, доступы, интеграции), чанкование, embeddings, таблицы из главы, HNSW-индекс. Реализуйте функцию search(query, user_id, tenant_id) -> list[Chunk].
Критерии приёмки:
- Индексация повторно запускается на тех же статьях и не пересчитывает embeddings (проверка по
content_hashи счётчику вызовов embedding API). - Удаление документа убирает его из выдачи немедленно, без задач синхронизации.
- ACL работает: пользователь без прав на раздел «биллинг» не видит биллинговых чанков ни при каком запросе — есть тест.
- На 10 тестовых запросах глазами проверено, что топ-5 релевантен; для одного «плохого» запроса разобрано, почему выдача слабая.
Подсказки:
- Для учебного объёма чанкуйте просто: по заголовкам/абзацам с лимитом токенов. Изощрённый chunking — тема главы 20, не тратьте вечер на него.
- Заведите
document_acl (document_id, user_id)с парой пользователей — этого хватит, чтобы прочувствовать «фильтр до ранжирования».
Задача 21.2 — миграция embedding-модели без даунтайма. ⭑⭑⭑
Смените embedding-модель хранилища из 21.1 (хотя бы на другую размерность того же провайдера). Условие: поиск работает непрерывно на протяжении всей миграции.
Критерии приёмки:
- Написан план миграции до начала: порядок шагов, момент переключения, план отката.
- Новые embeddings пишутся рядом со старыми (вторая колонка или строки с другим
embedding_model), запросы всё время ходят в консистентный набор. - Запрос эмбеддится той же моделью, что и чанки, в любой момент миграции — нарушение этого инварианта воспроизведено намеренно, чтобы увидеть, как тихо умирает качество.
- Старые embeddings удалены только после проверки качества на новой модели; место освобождено (
vacuum).
Подсказки:
- Самая частая ошибка миграции — сравнить вектор запроса новой модели с чанками старой: ошибки не будет, будет просто мусорная выдача. Поле
embedding_modelвwhereзащищает от этого структурно.