Данило (Dayfing)
Назад до публікацій
2 509 слів14 хв

RAG у production: гібридний пошук, pgvector, ACL і якість відповідей

Чому робоче демо ще не є production-пошуком

Retrieval-augmented generation, або RAG, це продукт даних, а не трюк із prompt. Запит перетворюється на один чи кілька пошуків, результати фільтруються за особою та орендарем, модель отримує обмежений контекст, а відповідь повертається з доказами. Кожен етап може бути правильним окремо, але весь результат усе одно може бути хибним. Пошук здатен знайти корисний абзац іншого орендаря, якого викликач не має права бачити. Chunk може містити потрібне речення, але втратити заголовок, що задає йому зміст. Мовна модель може показати правдоподібний URL, якого серед знайдених джерел не було.

Тому production-контракт треба описати явно. Для кожного chunk зберігайте стабільний ідентифікатор документа, ревізію, розташування джерела, порядок chunk, мітки доступу, модель embedding і лексичне представлення. Відокремлюйте поточну ревізію від історичних. Забезпечуйте межу орендаря в PostgreSQL і повторюйте фільтр авторизації в запиті пошуку. Цитата має бути ідентифікатором, обраним із знайдених рядків, а не текстом, який модель може вигадати.

Ця схема працює в одній базі PostgreSQL і пізніше дає змогу винести лексичний пошук або reranker в окремий сервіс. Посібник з оцінювання AI-агентів детально описує тестовий набір, а посібник зі спостережуваності AI-агентів показує трасування етапів. Посібник із prompt injection та безпеки MCP пояснює, чому знайдений текст є даними, а не інструкціями.

Спочатку визначте одиницю пошуку, потім індекс

Чанкінг є першим рішенням щодо якості. Починайте зі структури джерела, а не з кількості символів. Залишайте заголовок разом із наступними абзацами, не відривайте рядок таблиці від його підпису та зберігайте межі списків. Chunk має самостійно відповідати на невелике запитання і водночас містити достатньо контексту для reranker. Рахуйте токени обраною моделлю embedding, адже ліміт символів по-різному працює для мов і коду.

Перекриття може зберегти речення, що перетинає межу, але воно також дублює терміни, збільшує роботу embedding і здатне змусити генератор повторюватися. Використовуйте найменше перекриття, яке виправляє помічені помилки на межах. Зберігайте source_start, source_end або якір джерела разом із нормалізованим hash тексту. Так цитата буде точною, а ingestion-завдання зможе пропускати незмінені chunk. Блок коду залишайте цілим, якщо має значення його синтаксис. Для довгого документа додавайте назву документа та шлях заголовків до тексту, який отримує embedder, але для показу залишайте чистий текст.

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);

На початку кожної транзакції задавайте значення параметризованим викликом, виконуйте пошук і фіксуйте або відкочуйте транзакцію. Відсутнє налаштування не збігається з жодним орендарем. Неправильне автентифіковане значення потрібно відхилити до відкриття транзакції бази. Застосунок все одно додає предикати tenant_id, поточної ревізії, deleted_at і ACL. RLS є останньою межею, а не причиною довіряти tenant ID або списку ролей від клієнта.

HNSW та IVFFlat мають різні експлуатаційні властивості

Без approximate-індексу pgvector виконує точний пошук найближчих сусідів. Це корисна опора для recall, а після селективного фільтра орендаря чи статусу такий пошук іноді залишається практичним. HNSW будує багаторівневий граф. Зазвичай він дає кращий компроміс між швидкістю та recall, але потребує більше пам'яті й довше будується. Йому не потрібні навчальні дані, тому його можна створити до заповнення таблиці. Параметри m і ef_construction впливають на розмір графа, роботу побудови та recall.

IVFFlat ділить вектори на списки й перевіряє частину найближчих списків. Він споживає менше пам'яті та швидше будується, але recall залежить від кількості списків, розподілу даних і кількості probes. Створюйте його після завантаження репрезентативних даних. Проєкт pgvector пропонує стартові евристики для вибору списків і налаштування ivfflat.probes. Це не benchmark для вашого навантаження. Вимірюйте на власних мовах, фільтрах і частоті оновлень.

Використовуйте оператор відстані, що відповідає контракту embedding. Косинусна відстань використовує <=>, внутрішній добуток <#>, а відстань L2 <->. Cosine HNSW-індекс має використовувати vector_cosine_ops, як у схемі. Додавайте детермінований вторинний ключ до сортування. Під час міграції створюйте індекс через CREATE INDEX CONCURRENTLY, щоб не блокувати звичайні записи, і пам'ятайте, що ця команда не працює всередині транзакції. Перевіряйте плани через EXPLAIN (ANALYZE, BUFFERS) на репрезентативних даних і порівнюйте approximate-результати з exact-пошуком.

Фільтрований approximate-пошук потребує уваги. pgvector застосовує звичайний фільтр після сканування approximate-індексу, тому малий список кандидатів може залишити замало рядків для конкретного орендаря чи ACL. Збільшуйте бюджет кандидатів, вмикайте iterative scans у встановленій версії pgvector, коли вони доступні, або додавайте селективний реляційний індекс. Для невеликої кількості значень фільтра допомагає частковий векторний індекс. Для багатьох орендарів list або hash partitioning ізолює групи, але тисячі partitions збільшують планування й витрати пам'яті. Документація pgvector попереджає, що спільний approximate-індекс дає векторам одного орендаря впливати на recall іншого.

Поєднайте лексичний намір і векторну схожість

Векторний пошук обробляє перефразування та близькі поняття. Лексичний пошук захищає точні ідентифікатори, коди помилок, назви продуктів, фрази в лапках і нові терміни, які embedding може представити погано. Надійний конвеєр запускає обидва пошуки на тому самому дозволеному наборі поточних ревізій, бере більше кандидатів, ніж буде показано, і об'єднує ранги, а не сирі scores.

Наведений нижче запит використовує повнотекстовий ранг 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 або пошуку, версію reranker і фінальні IDs, маскуючи або захищаючи чутливий текст. Посібник із prompt injection та безпеки MCP пояснює, чому знайдений текст є даними, а не інструкціями. Документ може містити prompt injection, тому prompt генерації має вказати, що уривки є недовіреними доказами і не можуть змінювати інструменти, дозволи чи системні правила.

Цитати мають бути даними, а не прикрасою

Кожна показана цитата повинна посилатися на знайдені document_id, revision, chunk_no, source_uri і зміщення або заголовок джерела. Попросіть генератор повернути IDs цитат поруч із твердженнями, а потім перевірте їх за точними рядками, переданими в контекст. Назву джерела та URL відображайте з бази. Невідомий ID видаляйте або позначайте твердження як непідтверджене. Модель не повинна створювати URL лише з назви документа.

Передавайте достатньо сусіднього контексту для пояснення результату, але зберігайте ID кожного chunk. Якщо сусідні chunk об'єднуються для моделі, не втрачайте походження кожного. Після зміни джерела цитата старої ревізії не має вирішуватися в поточній відповіді. У ризикових доменах показуйте дату ревізії, область доступу і явний стан «відповіді немає», коли доказів недостатньо.

Вимірюйте пошук і відповідь окремо

Створіть версійну оцінювальну вибірку з реальних запитань, очікуваних документів, прийнятних випадків без відповіді, ідентичностей орендарів, 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 відповіді не виправдовує витік одного chunk. З кожним результатом оцінки зберігайте модель embedding, версію чанкінгу, налаштування індексу, версію reranker, версію prompt і ревізію корпусу. Так регресія матиме відтворювану причину.

Безпечні та спостережувані оновлення і видалення

Використовуйте hash вмісту, щоб 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;

Це приклад вставки одного chunk. Реальний loader вставляє всі chunk ревізії до оновлення вказівника та перевіряє очікувану кількість рядків. Під час видалення спочатку зробіть документ невидимим для retrieval, встановивши deleted_at у тій самій межі авторизації. Фізично видаляйте chunk після строку зберігання, якого вимагають продукт і політика відповідності. Чистьте старі ревізії пакетами, стежте за 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 бази.

Практична послідовність запуску

Почніть із точного vector search і повнотекстового пошуку PostgreSQL на невеликому репрезентативному корпусі. Додайте вказівник ревізії та RLS до підключення реальних орендарів. Зафіксуйте розмічений evaluation set, а потім порівняйте HNSW і IVFFlat з exact-результатами. Додавайте RRF, reranking і цитати по одному етапу, щоб кожна зміна мала вимірюваний ефект. До широкого запуску перевірте оновлення, видалення, зміни ACL, повторне використання connection pool і невдалі побудови індексів.

Офіційна документація pgvector описує типи векторів, оператори відстані, HNSW, IVFFlat, фільтрований пошук, iterative scans, partitioning та обслуговування. Документація PostgreSQL охоплює повнотекстовий пошук, текстові GIN і GiST-індекси, політики row security, створення індексів, partitioning і MVCC. Наведені SQL та tradeoffs дотримуються цих інтерфейсів і не обіцяють універсальних latency, recall чи cost.

Інші публікації