Почему работающий прототип еще не является production-поиском
Retrieval-augmented generation, или RAG, это не прием для промпта, а полноценный продукт данных. Запрос превращается в один или несколько поисков, результаты фильтруются по личности и арендатору, модель получает ограниченный контекст, а ответ возвращается вместе с доказательствами. Каждый этап может быть по отдельности исправным, но итог все равно будет неверным. Поиск может найти полезный абзац другого арендатора, который вызывающий пользователь не должен видеть. Чанк может содержать нужное предложение, но потерять заголовок, задающий смысл. Языковая модель может показать правдоподобный URL, которого не было среди найденных источников.
Поэтому production-контракт нужно описать явно. Для каждого чанка храните стабильный идентификатор документа, ревизию, расположение источника, порядок чанка, метки доступа, модель embedding и лексическое представление. Текущую ревизию отделяйте от исторических. Границу арендатора обеспечивайте в PostgreSQL и повторяйте фильтр авторизации в запросе поиска. Цитата должна быть идентификатором, выбранным из найденных строк, а не текстом, который модель может придумать.
Такая схема работает в одной базе PostgreSQL и позднее позволяет вынести лексический поиск или reranker в отдельный сервис. В руководстве по оценке AI-агентов подробнее разобран тестовый набор, а руководство по наблюдаемости AI-агентов показывает трассировку этапов.
Сначала определите единицу поиска, затем выбирайте индекс
Чанкинг является первым решением о качестве. Начинайте со структуры источника, а не с количества символов. Оставляйте заголовок вместе со следующими абзацами, не разделяйте строку таблицы и ее подпись, сохраняйте границы списков. Чанк должен сам отвечать на небольшой вопрос и при этом содержать достаточно контекста для reranker. Считайте токены выбранной моделью embedding, потому что лимит символов по-разному соответствует языкам и коду.
Перекрытие помогает сохранить предложение, пересекающее границу, но дублирует термины, увеличивает число embedding и может заставить генератор повторяться. Используйте минимальное перекрытие, которое исправляет обнаруженные ошибки на границе. Храните source_start, source_end или якорь источника вместе с нормализованным хэшем текста. Это делает цитату точной и позволяет ingestion-задаче пропускать неизменившиеся чанки. Сохраняйте блок кода целиком, если важен его синтаксис. Для длинного документа добавляйте в текст для embedding название документа и путь заголовков, но показывайте пользователю чистое тело.
Embedding является версионируемым представлением текста, а не его вечным свойством. Записывайте имя модели, размерность, правило нормализации и время создания. Изменение любого параметра меняет порядок ближайших соседей. Объявление столбца как vector(1536) отклоняет вектор другой размерности, что защищает от случайного смешения моделей. Если нужно поддерживать несколько моделей, используйте разные столбцы или таблицы и отдельные индексы, не дополняйте векторы нулями молча.
Контракт данных PostgreSQL с границей арендатора
Следующая схема хранит указатель на текущую ревизию документа и всю метаинформацию, нужную для поиска и цитирования. Конфигурация english приведена для примера. Для мультиязычного корпуса используйте конфигурацию полнотекстового поиска для каждого языка или отдельный лексический сервис.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE rag_documents (
tenant_id uuid NOT NULL,
document_id uuid NOT NULL,
current_revision bigint NOT NULL,
source_uri text NOT NULL,
deleted_at timestamptz,
PRIMARY KEY (tenant_id, document_id)
);
CREATE TABLE rag_chunks (
chunk_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id uuid NOT NULL,
document_id uuid NOT NULL,
revision bigint NOT NULL,
chunk_no integer NOT NULL,
title text NOT NULL,
content text NOT NULL,
source_start integer NOT NULL,
source_end integer NOT NULL,
acl text[] NOT NULL DEFAULT ARRAY['public']::text[],
embedding vector(1536) NOT NULL,
embedding_model text NOT NULL,
search_tsv tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', content), 'B')
) STORED,
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, document_id, revision, chunk_no)
);
CREATE INDEX rag_chunks_search_tsv_idx
ON rag_chunks USING gin (search_tsv);
CREATE INDEX rag_chunks_acl_idx
ON rag_chunks USING gin (acl);
CREATE INDEX rag_chunks_tenant_idx
ON rag_chunks (tenant_id);
CREATE INDEX rag_chunks_embedding_hnsw_idx
ON rag_chunks USING hnsw (embedding vector_cosine_ops);
Если выбран approximate-индекс IVFFlat, используйте его параметры списков и probes вместо HNSW:
CREATE INDEX rag_chunks_embedding_ivfflat_idx
ON rag_chunks USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
Оставляйте один approximate-индекс для расстояния и пути доступа конкретного маршрута, если только миграция или намеренное сравнение не требует обоих.
Полнотекстовый поиск PostgreSQL представляет нормализованные лексемы как tsvector, а запрос как tsquery. Вычисляемый столбец убирает подготовку из пути запроса, а GIN обычно подходит для часто повторяемого поиска. Если нужны именно семантики BM25, используйте лексический движок или расширение, которое предоставляет BM25, и передавайте его результаты как набор кандидатов. ts_rank_cd в PostgreSQL является полезным лексическим рангом, но не BM25. Нельзя называть одно другим только потому, что оба возвращают число.
Для роли приложения включите row-level security на обеих таблицах и используйте локальную настройку арендатора, полученную из аутентифицированных claims. Роль, обслуживающая запросы, не должна быть владельцем таблиц. Иначе принудительно включите политики для владельца.
ALTER TABLE rag_documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE rag_documents FORCE ROW LEVEL SECURITY;
ALTER TABLE rag_chunks ENABLE ROW LEVEL SECURITY;
ALTER TABLE rag_chunks FORCE ROW LEVEL SECURITY;
CREATE POLICY rag_documents_tenant ON rag_documents
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);
CREATE POLICY rag_chunks_tenant ON rag_chunks
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);
В начале каждой транзакции задавайте значение параметризованным вызовом, затем выполняйте поиск и фиксируйте либо откатывайте транзакцию. Отсутствующая настройка не совпадет ни с одним арендатором. Неверное значение из claims нужно отклонить до открытия транзакции базы. Приложение все равно добавляет предикаты tenant_id, текущей ревизии документа, deleted_at и ACL. RLS является последней границей, а не поводом доверять tenant ID или списку ролей, переданному клиентом.
HNSW и IVFFlat решают разные эксплуатационные задачи
Без approximate-индекса pgvector выполняет точный поиск ближайших соседей. Он дает полезную опорную точку для recall и иногда остается достаточно быстрым после селективного фильтра арендатора или статуса. HNSW строит многоуровневый граф. Обычно он дает более выгодный компромисс скорости и полноты, но потребляет больше памяти и дольше строится. Для него не нужны обучающие данные, поэтому индекс можно создавать до заполнения таблицы. Параметры m и ef_construction влияют на размер графа, работу при сборке и recall.
IVFFlat делит векторы на списки и проверяет часть ближайших списков. Он требует меньше памяти и строится быстрее, однако recall зависит от количества списков, распределения данных и количества probes. Создавайте его после загрузки представительных данных. Проект pgvector предлагает начальные эвристики для выбора списков и настройки ivfflat.probes. Это не benchmark для вашей системы. Измеряйте результат на своих языках, фильтрах и частоте обновлений.
Используйте оператор расстояния, согласованный с контрактом embedding. Косинусное расстояние использует <=>, inner product использует <#>, а L2 использует <->. Для cosine HNSW нужен vector_cosine_ops, как в схеме. Добавляйте детерминированный вторичный ключ к сортировке. При миграции создавайте новый индекс concurrently, чтобы не блокировать обычные записи, и помните, что CREATE INDEX CONCURRENTLY нельзя выполнять внутри транзакции. Проверяйте план через EXPLAIN (ANALYZE, BUFFERS) на представительских данных и сравнивайте approximate-результаты с exact-результатами.
Фильтрованный approximate-поиск требует внимания. pgvector применяет обычный фильтр после сканирования approximate-индекса, поэтому маленький список кандидатов может дать слишком мало строк одного арендатора или ACL. Увеличьте бюджет кандидатов, включите iterative scans в поддерживаемой версии pgvector или добавьте селективный реляционный индекс. Для небольшого числа значений фильтра подходит partial vector index. Для большого числа арендаторов помогает list или hash partitioning, но тысячи partitions увеличивают время планирования и потребление памяти. Документация pgvector отдельно предупреждает, что общий approximate-индекс позволяет векторам одного арендатора влиять на recall другого.
Объедините лексическое намерение и векторное сходство
Векторный поиск находит перефразирования и близкие понятия. Лексический поиск защищает точные идентификаторы, коды ошибок, названия продуктов, цитаты и новые термины, которые embedding может представить плохо. Надежный конвейер выполняет оба поиска по одному и тому же разрешенному набору текущих ревизий, берет больше кандидатов, чем показывает пользователю, и объединяет ранги, а не исходные scores.
Ниже лексической частью служит полнотекстовый rank PostgreSQL. В системе с BM25-сервисом замените text_hits его кандидатами и сохраните тот же контракт chunk_id, text_rank, tenant, revision и ACL. Reciprocal Rank Fusion не предполагает, что cosine distance и BM25 score имеют одну шкалу.
WITH q AS (
SELECT $1::vector AS embedding,
websearch_to_tsquery('english', $2::text) AS tsq,
$3::uuid AS tenant_id,
$4::text[] AS roles
),
vector_hits AS (
SELECT c.chunk_id,
row_number() OVER (
ORDER BY c.embedding <=> q.embedding, c.chunk_id
) AS vector_rank
FROM rag_chunks c
JOIN rag_documents d
ON d.tenant_id = c.tenant_id
AND d.document_id = c.document_id
AND d.current_revision = c.revision
CROSS JOIN q
WHERE c.tenant_id = q.tenant_id
AND d.deleted_at IS NULL
AND c.acl && q.roles
ORDER BY c.embedding <=> q.embedding, c.chunk_id
LIMIT 50
),
text_hits AS (
SELECT c.chunk_id,
row_number() OVER (
ORDER BY ts_rank_cd(c.search_tsv, q.tsq) DESC, c.chunk_id
) AS text_rank
FROM rag_chunks c
JOIN rag_documents d
ON d.tenant_id = c.tenant_id
AND d.document_id = c.document_id
AND d.current_revision = c.revision
CROSS JOIN q
WHERE c.tenant_id = q.tenant_id
AND d.deleted_at IS NULL
AND c.acl && q.roles
AND c.search_tsv @@ q.tsq
ORDER BY ts_rank_cd(c.search_tsv, q.tsq) DESC, c.chunk_id
LIMIT 50
),
ranked AS (
SELECT chunk_id, vector_rank, NULL::bigint AS text_rank
FROM vector_hits
UNION ALL
SELECT chunk_id, NULL::bigint AS vector_rank, text_rank
FROM text_hits
),
candidate_scores AS (
SELECT chunk_id,
min(vector_rank) AS vector_rank,
min(text_rank) AS text_rank
FROM ranked
GROUP BY chunk_id
)
SELECT c.chunk_id,
c.document_id,
c.chunk_no,
c.title,
c.content,
d.source_uri,
s.vector_rank,
s.text_rank,
coalesce(1.0 / (60.0 + s.vector_rank), 0.0) +
coalesce(1.0 / (60.0 + s.text_rank), 0.0) AS rrf_score
FROM candidate_scores s
JOIN rag_chunks c ON c.chunk_id = s.chunk_id
JOIN rag_documents d
ON d.tenant_id = c.tenant_id
AND d.document_id = c.document_id
AND d.current_revision = c.revision
WHERE d.deleted_at IS NULL
ORDER BY rrf_score DESC, c.chunk_id
LIMIT 8;
Массив ролей строит слой авторизации. Условие acl && roles означает пересечение хотя бы по одной метке. Если политика требует всех меток, владельца или временных окон, используйте другой оператор или столбец is_public. Текст запроса и embedding передавайте параметрами. websearch_to_tsquery терпим к синтаксису пользователя, а to_tsquery ожидает корректные операторы и не должен получать непроверенный текст.
Передавайте reranker только разрешенных кандидатов
Cross-encoder или другой reranker видит запрос и каждый кандидат одновременно. Поэтому он может лучше различать близкие семантические совпадения, чем один embedding. Но ему нужны дополнительные вычисления и последовательный этап. Получите ограниченный набор кандидатов, примените tenant, revision, удаление и ACL до reranker, а затем передайте только поля, нужные для ранжирования. Нельзя отправлять неразрешенные строки внешней модели даже тогда, когда итоговый ответ их не покажет.
Сохраняйте исходные векторные и лексические ранги для диагностики. Записывайте IDs кандидатов, режим индекса, параметры probes или search, версию reranker и итоговые IDs. Чувствительный текст нужно маскировать или защищать. Руководство по prompt injection и MCP security объясняет, почему найденный текст является данными, а не инструкциями. Документ может содержать prompt injection, поэтому в системном промпте генерации явно укажите, что отрывки являются недоверенными доказательствами и не могут менять инструменты, разрешения или системные правила.
Цитаты должны быть данными, а не украшением
Каждая показанная цитата должна разрешаться в найденные document_id, revision, chunk_no, source_uri и смещение или заголовок источника. Попросите генератор вернуть IDs цитат рядом с утверждениями, затем проверьте IDs по точным строкам, переданным в контекст. Название источника и URL берите из базы. Неизвестный ID удаляйте или отмечайте как неподтвержденное утверждение. Не позволяйте модели строить URL из одного заголовка документа.
Передавайте соседний контекст, если он нужен для смысла, но сохраняйте ID каждого чанка. При объединении соседних чанков для модели не теряйте их происхождение. После изменения источника цитата старой ревизии не должна разрешаться в текущем ответе. В рискованных доменах показывайте дату ревизии, область доступа и явное состояние «нет ответа», когда доказательств нет.
Измеряйте поиск и ответ как разные качества
Создайте версионируемый набор оценки из реальных вопросов, ожидаемых документов, допустимых случаев «нет ответа», identities арендаторов, ACL-меток и языков. Включите точные lookup-запросы, перефразирования, вопросы с несколькими шагами, вопросы по устаревшим документам и adversarial-запросы. Держите закрытую тестовую часть, чтобы настройка не подгонялась под примеры разработки.
Для поиска измеряйте recall@k на нескольких cutoff, reciprocal rank или nDCG для порядка и долю результатов, удовлетворяющих контракту авторизации. Для генерации измеряйте precision утверждений, grounded в контексте, precision и coverage цитат, полноту ответа, качество отказа и калибровку no-answer. При неоднозначных вопросах полезна проверка людьми. Сравнивайте exact search с HNSW или IVFFlat на одном snapshot, чтобы измерить потерю recall, а не судить о ней по latency.
Запускайте отрицательные security-тесты. Пользователь должен не получить известный секрет другого арендатора, ACL после смены должен применяться в следующем запросе, а удаленный документ не должен появляться даже сразу после удаления. Высокий score качества ответа не оправдывает одну утечку чанка. С каждым результатом оценки сохраняйте модель embedding, версию чанкинга, настройки индекса, версию reranker, версию промпта и ревизию корпуса. Так регрессия получает воспроизводимую причину.
Делайте обновления и удаления наблюдаемыми и безопасными
Используйте хэши содержимого, чтобы ingestion был идемпотентным. Разберите документ, создайте чанки, получите embedding пакетами и запишите новую ревизию до перемещения указателя документа. Переключение указателя может быть короткой транзакцией, тогда читатель увидит либо старую полную ревизию, либо новую полную ревизию. Записывайте модель embedding в каждой строке и запускайте backfill при смене модели. Не смешивайте в одном столбце векторы разных размерностей.
BEGIN;
SELECT set_config('app.tenant_id', $1::text, true);
INSERT INTO rag_chunks (
tenant_id, document_id, revision, chunk_no, title, content,
source_start, source_end, acl, embedding, embedding_model
)
VALUES ($1::uuid, $2::uuid, $3::bigint, $4::integer, $5::text, $6::text,
$7::integer, $8::integer, $9::text[], $10::vector, $11::text)
ON CONFLICT (tenant_id, document_id, revision, chunk_no) DO UPDATE
SET title = EXCLUDED.title,
content = EXCLUDED.content,
source_start = EXCLUDED.source_start,
source_end = EXCLUDED.source_end,
acl = EXCLUDED.acl,
embedding = EXCLUDED.embedding,
embedding_model = EXCLUDED.embedding_model,
updated_at = now();
INSERT INTO rag_documents (
tenant_id, document_id, current_revision, source_uri, deleted_at
)
VALUES ($1::uuid, $2::uuid, $3::bigint, $12::text, NULL)
ON CONFLICT (tenant_id, document_id) DO UPDATE
SET current_revision = EXCLUDED.current_revision,
source_uri = EXCLUDED.source_uri,
deleted_at = NULL;
COMMIT;
Это пример вставки одного чанка. Реальный loader вставляет все чанки ревизии до обновления указателя и проверяет ожидаемое количество строк. При удалении сначала сделайте документ невидимым для retrieval, установив deleted_at в той же границе авторизации. Физически удаляйте чанки после retention-периода, требуемого продуктом и политикой соответствия. Чистите старые ревизии пакетами, следите за dead tuples и запускайте VACUUM (ANALYZE) по необходимости. После сильной ротации HNSW может сделать vacuum дорогим. Concurrent reindex затронутого индекса перед vacuum иногда уменьшает цену, но это нужно измерить на своей таблице.
Планируйте latency и cost по этапам
Разделяйте p50, p95 и p99 latency для нормализации запроса, embedding, лексического поиска, vector search, fusion, reranking, генерации и проверки цитат. Дополнительно записывайте количество кандидатов, число отфильтрованных строк, количество токенов, повторы и cache hits. Быстрый vector query может быть скрыт timeout провайдера embedding. Дешевый retriever становится дорогим, если отправляет слишком много отрывков reranker или генератору.
Пакетно создавайте document embeddings и повторяйте ошибки с ограниченным backoff. Кэшируйте только стабильные и не чувствительные артефакты, добавляя в ключ версию модели, нормализацию и область tenant. Кэш запросов embedding требует privacy review, поскольку повторяющийся запрос может раскрыть интересы разных пользователей. Выбирайте HNSW или IVFFlat по измеренным recall, памяти, времени сборки, поведению записей и tail latency, а не по общему benchmark. Лимиты кандидатов и бюджет reranker должны настраиваться для маршрута или арендатора.
На production-дашборде показывайте долю пустых результатов, ответы без цитат, ошибки проверки цитат, отказы ACL, попадания старых ревизий, состояние сборки индекса, здоровье vacuum и выборки recall approximate против exact. Так наблюдаемость AI-агентов связывается с набором оценки AI-агентов. Сигнализируйте об изменениях этих долей, а не только о CPU базы.
Практическая последовательность запуска
Начните с exact vector search и полнотекстового поиска PostgreSQL на небольшом представительском корпусе. Добавьте pointer ревизии и RLS до подключения реальных арендаторов. Зафиксируйте размеченный evaluation set, затем сравните HNSW и IVFFlat с exact-результатами. Подключайте RRF, reranking и цитаты по одному этапу, чтобы каждое изменение имело измеримый эффект. До широкого запуска проверьте обновления, удаления, смену ACL, повторное использование соединений и неудачные сборки индексов.
Официальная документация pgvector описывает типы векторов, операторы расстояния, HNSW, IVFFlat, фильтрованный поиск, iterative scans, partitioning и обслуживание. Документация PostgreSQL описывает полнотекстовый поиск, текстовые GIN и GiST-индексы, политики row security, создание индексов, partitioning и MVCC. Приведенные SQL и tradeoffs следуют этим интерфейсам и не обещают универсальные latency, recall или cost.