Începe cu o felie verticală, nu cu o platformă
Cel mai sigur mod de a pierde un trimestru pe RAG este să începi prin a indexa tot. Construiești ingestie pentru șase tipuri de conținut, petreci două săptămâni certându-te despre baze de date vectoriale și ajungi la un câmp de căutare în care nimeni nu are încredere. Începe din capătul celălalt: ia o întrebare pe care utilizatorii o adresează deja echipei de suport și răspunde doar la ea, pentru o singură categorie de utilizatori, într-un singur loc din interfață.
O primă felie bună are patru proprietăți. Corpusul este delimitat și se află deja în baza ta de date. Un răspuns greșit este jenant, nu periculos. Există deja un ecran pe care poți pune funcționalitatea. În plus, poți stabili dacă un răspuns este corect pur și simplu citindu-l. Căutarea în documentație, „explică-mi înregistrarea asta” și reducerea solicitărilor către suport sunt cazuri potrivite. Orice implică bani ori recomandări medicale sau juridice nu este potrivit — cel puțin nu pentru prima versiune.
Decide ce intră efectiv în index
Calitatea retrievalului se decide înainte să calculezi primul embedding. Contează două întrebări: ce conținut răspunde întrebării și ce conținut cuprinde doar cuvintele din ea.
- Preferă sursele canonice celor derivate. Indexează articolul din centrul de ajutor, nu cele zece mesaje de chat în care se discută despre el.
- Exclude tot ce nu poți data. Dacă un chunk nu are updated_at, nu poți stabili când devine perimat, iar asistentul tău va cita cu convingere o politică schimbată acum un an.
- Păstrează structura. Titlurile, subtitlurile, numele de produse și identificatorii constituie semnale utile pentru retrieval — dacă le elimini, pierzi informații valoroase pe care le ai deja.
- Salvează metadatele după care vei filtra mai târziu: tenant, limbă, zonă de produs, vizibilitate, ID-ul sursei. Să adaugi o coloană de filtrare după ce ai generat reprezentări vectoriale pentru un milion de rânduri este neplăcut; să refaci toate embeddingurile pentru că nu ai păstrat ID-ul sursei este și mai rău.
Chunking: întâi structura, apoi dimensiunea
Chunkingul este etapa în care majoritatea sistemelor RAG pierd discret din calitate. O fereastră fixă de 1.000 de caractere aplicată documentelor cu subtitluri va tăia definițiile în două și va reuni două secțiuni fără legătură într-un singur vector. Împarte întâi după structură — subtitluri, elemente de listă, rânduri de tabel, limite de funcții — apoi grupează acele unități până la o dimensiune țintă, în loc să tai mecanic, fără să ții cont de limitele structurale.
Valori rezonabile de pornire, pe care le ajustezi apoi pe setul tău de evaluare: aproximativ 300–800 de tokeni pentru fiecare chunk, o suprapunere de 10–15% și o regulă fermă ca un chunk să nu includă niciodată conținut din două documente. Suprapunerea te protejează împotriva tăierii la mijlocul unei propoziții; peste circa 20%, obții mai ales rezultate duplicate și costuri mai mari.
Adaugă context în fața fiecărui chunk înainte de embedding. Un chunk care spune „Trebuie reînnoit la fiecare 12 luni” este inutil singur. Dacă incluzi contextul „Facturare › Abonamente › Reînnoire — Trebuie reînnoit la fiecare 12 luni” înainte de embedding, textul va fi regăsit corect și va rămâne clar după introducerea în prompt.
type Chunk = { text: string; heading: string; sourceId: string; ord: number };
export function chunkSections(
sections: { heading: string; body: string }[],
sourceId: string,
maxChars = 2400,
overlapChars = 300
): Chunk[] {
const chunks: Chunk[] = [];
for (const section of sections) {
const prefix = section.heading + '\n\n';
let start = 0;
while (start < section.body.length) {
const end = Math.min(start + maxChars, section.body.length);
// prefer a paragraph break near the end of the window
const cut = section.body.lastIndexOf('\n\n', end);
const stop = cut > start + maxChars / 2 ? cut : end;
chunks.push({
text: prefix + section.body.slice(start, stop).trim(),
heading: section.heading,
sourceId,
ord: chunks.length,
});
if (stop >= section.body.length) break;
start = Math.max(stop - overlapChars, start + 1);
}
}
return chunks;
}Embeddings și unde stau vectorii
Pentru o primă felie într-un produs existent, pgvector în instanța Postgres pe care o rulezi deja este aproape întotdeauna răspunsul corect. Chunkurile stau lângă rândurile din care provin, filtrele pentru tenant sunt SQL obișnuit, tranzacțiile păstrează indexul sincronizat cu datele-sursă și nu introduci o componentă operațională nouă. Până la câteva milioane de chunkuri cu un index HNSW, asta nu este un compromis.
Alege Pinecone sau Weaviate când unul dintre aceste lucruri este cu adevărat valabil: ai nevoie să scalezi indexul independent de baza de date principală, vrei sharding și replicare gestionate pe care oricum nu le-ai construi singur sau echipa ta nu își poate asuma încă o extensie de Postgres. Acestea sunt motive reale. „Este baza de date vectorială pe care o folosește toată lumea” nu este.
create extension if not exists vector;
create table doc_chunk (
id bigserial primary key,
tenant_id uuid not null,
source_id text not null,
locale text not null,
heading text,
content text not null,
embedding vector(1536) not null,
model text not null, -- which model produced this vector
updated_at timestamptz not null default now()
);
-- ANN index: build it after the initial bulk load, not before
create index doc_chunk_embedding_idx
on doc_chunk using hnsw (embedding vector_cosine_ops)
with (m = 16, ef_construction = 64);
-- the filters you retrieve with must be indexed too
create index doc_chunk_scope_idx on doc_chunk (tenant_id, locale);
-- lexical half of hybrid search
alter table doc_chunk add column tsv tsvector
generated always as (
to_tsvector('simple', coalesce(heading, '') || ' ' || content)
) stored;
create index doc_chunk_tsv_idx on doc_chunk using gin (tsv);Căutare hibridă, apoi re-ranking
Căutarea pur vectorială eșuează exact la interogările pe care le scriu utilizatorii: coduri de eroare, SKU-uri, numere de factură, nume de funcții, substantive proprii rare. Căutarea lexicală eșuează în cazul parafrazelor. Rulează ambele tipuri de căutare și combină listele — reciprocal rank fusion necesită aproximativ douăsprezece rânduri, nu are nevoie de ajustări și funcționează chiar dacă cele două scoruri folosesc scale diferite.
with semantic as (
select id, row_number() over (order by embedding <=> $1) as rank
from doc_chunk
where tenant_id = $2 and locale = $3
order by embedding <=> $1
limit 50
),
lexical as (
select id, row_number() over (order by ts_rank_cd(tsv, q) desc) as rank
from doc_chunk, websearch_to_tsquery('simple', $4) q
where tenant_id = $2 and locale = $3 and tsv @@ q
limit 50
)
select coalesce(s.id, l.id) as id,
coalesce(1.0 / (60 + s.rank), 0)
+ coalesce(1.0 / (60 + l.rank), 0) as score
from semantic s
full outer join lexical l on l.id = s.id
order by score desc
limit 20;Supune apoi primele douăzeci de rezultate combinate unei etape de re-ranking, folosind un cross-encoder sau un model de limbaj mic, și păstrează-le pe cele mai bune patru–șase. Re-rankingul este pasul cu cel mai mare efect în majoritatea sistemelor RAG: embeddingurile comprimă un chunk într-un singur vector înainte de a avea acces la interogare, în timp ce un re-ranker citește interogarea și chunkul împreună. Alocă acestui pas un buget de latență, apoi redu valoarea lui k; de multe ori, timpul total scade deoarece promptul de generare este mult mai scurt.
Evaluează retrievalul înainte să atingi promptul
Nu poți îmbunătăți ce nu măsori, iar „pare mai bine” nu este o măsurătoare. Construiește un set de referință înainte să ajustezi ceva: 30–50 de întrebări reale, fiecare etichetată cu ID-urile chunkurilor care chiar răspund la ea. Doi ingineri și un coordonator al echipei de suport pot face asta într-o după-amiază, iar setul va rămâne util chiar și după trei rescrieri ale promptului.
- Recall@k — apare undeva în primele k rezultate un chunk corect? Dacă recall@20 este slab, niciun prompt nu te salvează: repară chunkingul, filtrele sau interogarea.
- Precision@k după re-ranking — din chunkurile pe care chiar le pui în prompt, câte sunt relevante? Asta determină și costul, și halucinațiile.
- Fundamentare — este fiecare afirmație din răspuns susținută de un chunk regăsit? Evalueaz-o cu ajutorul unui model, prin raportare la contextul regăsit, și verifică manual modelul evaluator până când ai încredere în rezultate.
- Rata de refuz la întrebări din afara domeniului acoperit. Un asistent care nu spune niciodată „nu știu” nu respectă limitele corpusului tău; ghicește.
Rulează evaluarea în CI la fiecare modificare de chunking, model de embedding, parametri de retrieval sau prompt. Durează câteva minute și este singurul mecanism care te protejează de o regresie tăcută introdusă în producție într-o zi de vineri.
Asamblarea promptului și citările
Păstrează această parte cât mai simplă. Folosește un mesaj de sistem care stabilește domeniul acoperit și regula de refuz, urmat de chunkurile regăsite, cu identificatori stabili și titlurile surselor, apoi de întrebare. Cere citări după identificator și validează-le după generare: dacă modelul citează un ID care nu era în context, răspunsul acela este un bug, nu o ciudățenie.
function buildMessages(question: string, chunks: Chunk[]) {
const context = chunks
.map((c, i) => '[' + (i + 1) + '] ' + c.heading + '\n' + c.text)
.join('\n\n');
return [
{
role: 'system' as const,
content:
'Answer only from the CONTEXT. If the context does not contain the ' +
'answer, say you do not know and point to support. ' +
'Cite the sources you used as [n] after each claim.',
},
{
role: 'user' as const,
content: 'CONTEXT:\n' + context + '\n\nQUESTION: ' + question,
},
];
}Afișează citările în interfață ca linkuri către documentul-sursă. Asta schimbă comportamentul utilizatorilor: oamenii verifică, raportează un chunk greșit în loc să spună că „AI-ul e prost”, iar tu primești gratuit un canal de feedback care îți alimentează setul de evaluare.
Permisiuni și filtrare pe tenant
Aici o funcționalitate utilă se poate transforma într-un incident. Regula este simplă: filtrează la momentul interogării, în aceeași interogare, folosind identitatea apelantului din sesiune — nu filtra niciodată după ce modelul a citit deja rezultatele și nu avea niciodată încredere într-un ID de tenant primit de la client.
- Trimite tenantul și vizibilitatea ca parametri ai interogării de retrieval și fă acele coloane non-nullable în schemă, astfel încât absența unui filtru să producă o eroare explicită.
- Oglindește ACL-urile aplicației pe rândurile de chunkuri și reindexează un document când i se schimbă permisiunile, nu doar când i se schimbă textul.
- Scrie testul: utilizatorul A pune o întrebare al cărei singur răspuns bun se află în datele utilizatorului B, iar asistentul trebuie să spună că nu știe.
Dacă modelul tău de permisiuni este prea complex pentru a fi reprodus fidel, limitează prima versiune la conținut accesibil tuturor utilizatorilor tenantului. Livreaz-o, învață din ea, apoi extinde-o.
Latență și cost sub control
O cerere RAG presupune embedding, retrieval, re-ranking și generare, iar utilizatorul percepe toate cele patru etape. Câteva măsuri ajută în mod constant.
- Păstrează embeddingurile în cache, folosind drept cheie hashul interogării normalizate. Întrebările repetate și cele bazate pe șabloane sunt mult mai frecvente decât se așteaptă lumea.
- Trimite răspunsul în streaming. Utilizatorii percep în primul rând timpul până la primul token; optimizează timpul total după aceea.
- Limitează contextul agresiv. Șase chunkuri bune oferă rezultate mai bune decât douăzeci mediocre, atât calitativ, cât și din perspectiva costurilor.
- Folosește un model ieftin pentru rescrierea interogării și pentru re-ranking și păstrează modelul scump pentru răspunsul final.
- Stabilește un timeout pentru fiecare etapă, cu un fallback definit, și treci la căutarea lexicală simplă în loc să returnezi o eroare.
Urmărește costul pentru fiecare întrebare la care sistemul răspunde, nu costul pe token. Numărul de tokeni este un detaliu de implementare; costul unui răspuns util este un indicator relevant pentru afacere, pe care îl poți exprima într-o propoziție.
Observabilitate
Înregistrează tot traseul de retrieval: interogarea brută, interogarea rescrisă, filtrele aplicate, ID-urile regăsite cu scorurile lor, ordinea după re-ranking, numărul de tokeni din prompt, modelul, latența pe fiecare etapă și răspunsul final cu citările lui. Fără asta, orice raportare de tip „AI-ul a dat un răspuns greșit” este imposibil de verificat. Cu aceste date, majoritatea problemelor se diagnostichează într-un minut — iar diagnosticul este de obicei că chunkul corect nu a fost regăsit deloc.
Adaugă o opțiune de vot negativ cu un câmp liber pentru motiv și trimite răspunsurile direct în backlogul de evaluări. Erorile întâlnite de utilizatori reali valorează mai mult decât orice set sintetic de întrebări pe care îl poți scrie.
Lansează cu un feature flag
Rulează mai întâi funcționalitatea fără rezultate vizibile pentru utilizatori: execută retrievalul pe trafic real, înregistrează tot și nu genera nimic. Vei identifica fără cost suplimentar erorile de filtrare și interogările executate pe un corpus gol înainte să vadă cineva un răspuns. Apoi activează generarea pentru propria echipă și, ulterior, pentru un procent mic de utilizatori, cu un buton de oprire care nu necesită deployment.
Păstrează intact fluxul existent, fără AI. Asistentul completează câmpul de căutare; nu îl înlocuiește până când cifrele nu arată că schimbarea este justificată.
Listă de verificare
- O întrebare, un ecran, un corpus — o felie pe care o poți evalua citind.
- Chunkuri împărțite după structură, cu subtitluri adăugate în față și metadate de sursă salvate.
- Vectori lângă datele tale, în pgvector, dacă nu ai un motiv explicit să faci altfel.
- Retrieval hibrid, cu rezultate combinate și apoi reordonate până rămân câteva chunkuri.
- Un set de referință de 30–50 de întrebări rulat în CI, cu recall@k și fundamentare.
- Filtre de tenant și de permisiuni incluse în interogarea de retrieval, cu un test care confirmă că filtrarea funcționează.
- Citări afișate și validate; un ID de citare necunoscut tratat ca bug.
- Trasee complete de retrieval înregistrate, vot negativ care alimentează setul de evaluare.
- Un feature flag, un buton de oprire și fluxul de căutare existent încă funcțional.
Dacă un element din listă pare scump pentru echipa ta, este o informație utilă despre felia pe care ai ales-o. De obicei înseamnă că adevăratul proiect este corpusul sau modelul de permisiuni — iar asta se află mult mai ieftin în prima săptămână decât în luna a treia.