Cum adaugi RAG într-un produs existent fără rescriere

RAG este prezentat de obicei ca o arhitectură. Într-un produs care există deja, seamănă mai degrabă cu o funcționalitate: o întrebare, un ecran, un corpus. Iată cum o delimităm, o construim, o măsurăm și o lansăm — fără să atingem restul sistemului.

Î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ță.

PRODUS EXISTENTÎntrebare utilizatoro singură interfațăEmbeddingtext → vectorRetrieval hibridpgvector + tsvectorRe-ranking20 → 5 chunk-uriPrompt + LLMchunk-uri cu ID-uri stabileFiltru tenant + permisiuniîn aceeași interogareTrace de retrieval salvatinterogare · filtre · ID-uri · scoruri · promptRăspuns cu citărirăspunde doar din corpus
Fig. 1Anatomia unei cereri RAG într-un produs existent

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.

FEREASTRĂ FIXĂ: 1.000 DE CARACTEREchunk 1chunk 2chunk 3definiție tăiată în douăSTRUCTURA ÎNTÂIGHID › INSTALAREGHID › CONFIGURAREGHID › LIMITEfiecare chunk include calea titlurilor
Fig. 2Ferestrele fixe taie definiții; chunkurile structurale le păstrează întregi

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.

Interogarecod de eroare, SKU, parafrazăCăutare vectorialătop 50 · pgvectorCăutare lexicalătop 50 · tsvectorReciprocal rank fusion→ 20 candidațiCross-encoder re-ranking→ 4–6 chunk-uricătre promptNiciuna dintre liste nu e de încredere singură: vectorii ratează tokenii exacți, cuvintele-cheie ratează parafrazele.
Fig. 3Căutare hibridă: două liste combinate, apoi reordonate
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.

Set de referință30–50 de întrebări reale + chunk-uri așteptateRulează retrieval-ulacelași cod din producțieMăsoarăRecall@k · MRR · precizieSchimbă un singur lucruchunking · k · filtre · re-rankerO SCHIMBARE PE RULARE
Fig. 4Bucla de evaluare: câte o schimbare pe rulare, măsurată pe setul de referință
  • 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.

FEATURE FLAGRulare invizibilăretrieval + log, fără generareUtilizatori internicitește trace-urileGrup mic de utilizatorikill switch pregătitToți utilizatoriiflag eliminatErorile de filtrare și interogările pe corpus gol apar gratuit, înainte să vadă cineva un răspuns.
Fig. 5Lansare cu feature flag: de la rulare invizibilă la acces general

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.

Lucrezi la ceva asemănător?

Începe un proiect