Чаму рабочае дэма яшчэ не з'яўляецца 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);
Калі IVFFlat абраны як approximate-індэкс, выкарыстоўвайце яго настройкі спісаў і 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. Generated-слупок выносіць вылічэнне з шляху запыту, а 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 можа прадставіць дрэнна. Надзейны pipeline запускае абодва пошукі на тым самым дазволеным наборы бягучых рэвізій, бярэ больш кандыдатаў, чым пакажа, і аб'ядноўвае рангі, а не сырыя 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, delete і 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.