RAG cu mai multe salturi la scară: în interiorul bazei de cunoștințe Opal a Teilor
Majoritatea sistemelor RAG de producție se rup în momentul în care un utilizator pune o întrebare reală. Demo-urile folosesc întrebări de tipul "Care este politica noastră de retur?" — o singură căutare atomică. Utilizatorii reali întreabă "Dacă un client a cumpărat un solitar de 1.2 carate în martie trecut pe planul cu rate, care este soldul rămas și care este politica noastră de schimb dacă vrea să facă upsize?"
Acea întrebare nu este o singură recuperare. Este un lanț: identifică clientul, găsește comanda, caută istoricul plăților, caută politica de schimb, sintetizează un răspuns cu citări. RAG-ul single-shot nu poate face asta. Multi-hop RAG poate, iar a face acest lucru corect în producție ne-a luat șase luni de iterație pe Opal Knowledge Base de la Teilor — asistentul AI pe care l-am construit pentru cel mai mare brand de bijuterii de lux din România.
Acest articol este arhitectura, compromisurile și lucrurile care s-au rupt.
Setup-ul
Angajații Teilor aveau nevoie de un singur asistent AI care să poată:
- Răspunde la întrebări de politică din sute de documente interne (PDF-uri, fișiere Word, prezentări, foi de calcul)
- Trage date live din ERP-ul lor Opal — produse, clienți, comenzi, facturi, oportunități de vânzare
- Mențină contextul conversațional între întrebări de urmărire
- Citeze fiecare afirmație cu o sursă
- Funcționeze în română și engleză, adesea în aceeași conversație
Trei recuperări din trei sisteme diferite, combinate într-un singur răspuns streamuit. Cu citări. Cu autentificare per-utilizator, astfel încât fiecare angajat să vadă doar ce are permisiunea să vadă. În producție. Cu latență acceptabilă.
Arhitectura, într-o singură suflare
Sistemul este trei servicii în trei limbaje, fiecare ales pentru punctele sale forte:
- Backend Go — orchestrare, pipeline RAG, clasificare de query, streaming, persistență, joburi de fundal
- Microserviciu Python FastAPI — reranking cu cross-encoder folosind BAAI/bge-reranker-v2-m3
- Frontend React 19 — UI de chat, mesagerie în timp real, shell PWA
Planul de date:
- Qdrant pentru căutare vectorială peste embedding-urile documentelor chunkificate (bge-m3, multilingv)
- PostgreSQL pentru istoricul conversațiilor, datele utilizatorilor, coada de joburi (via River)
- API REST Opal ERP pentru date CRM live, accesate cu credențiale bearer per-utilizator
- Anthropic Claude pentru generarea răspunsului, streamuit via Server-Sent Events
Motivul pentru care acest stack funcționează: fiecare sistem răspunde la un tip diferit de întrebare, iar orchestratorul decide pe care să-l invoce.
Clasificarea query-urilor — eroul necântat
RAG-ul single-shot trimite fiecare întrebare prin aceeași conductă. Multi-hop RAG începe prin a decide ce fel de întrebare este aceasta. Clasificatorul Teilor sortează query-urile primite în patru găleți:
- Căutare în documente. "Care este politica noastră de retur pentru inelele de logodnă?" → căutare vectorială peste documente, fără apel CRM.
- Căutare CRM. "Arată-mi comenzile clientului 4821." → apel plugin CRM, fără căutare în documente.
- Hibrid. "Care este politica de schimb pentru comanda la care mă uit?" → ambele, combinate.
- Conversațional. "Mulțumesc, asta a ajutat." → fără recuperare, răspuns LLM direct.
Clasificatorul este el însuși un mic apel LLM, cu un prompt care include contextul conversațional recent. Este rapid (sub 400ms) și determină întreaga conductă din aval. Dacă greșești, utilizatorul primește fie chunk-uri de document irelevante, fie o căutare CRM halucinată. Dacă faci corect, restul sistemului are intrări curate.
Am încercat mai întâi euristici manuale (potrivire de cuvinte cheie, regex). Au eșuat sever la query-urile multilingve. Clasificatorul LLM a câștigat detașat și nu ne-am mai uitat înapoi.
Recuperarea documentelor — părțile care contează
Partea de documente este cea mai "RAG de manual" a stack-ului, dar câteva detalii nu erau evidente în avans.
Docling pentru parsare, nu pdf-extract. Am început cu extracție naivă de text PDF. Tabele tocate, note de subsol în-line ca și cum ar fi text de corp, slide deck-uri redate ca gibberish. Docling — biblioteca open-source de înțelegere a documentelor de la IBM — extrage structura: tabelele rămân tabele, slide deck-urile sunt parsate per slide, titlurile devin titluri. Îmbunătățirea calității recuperării nu a fost subtilă.
bge-m3 pentru embeddings, rulat via Ollama. Multilingvul era non-negociabil; bge-m3 gestionează bine 100+ limbi și este suficient de mic pentru a rula pe GPU-uri commodity. Auto-găzduit via Ollama menține conținutul documentelor pe infrastructura Teilor.
Chunking după structură, nu după numărul de tokeni. Default-ul "1.000 tokeni cu suprapunere de 200 tokeni" producea chunk-uri care traversau granițele de secțiune și confundau recuperarea. Chunkingul după structura documentului (secțiune → subsecțiune → paragraf) a făcut recuperarea substanțial mai precisă.
Rerankingul nu este opțional. Top-10 rezultate de căutare vectorială sunt zgomotoase. Recuperăm top-50, apoi facem reranking cu cross-encoder-ul bge-reranker-v2-m3 (rulând în microserviciul Python), păstrând top 5-8 pentru LLM. Cost de latență: ~300ms. Câștig de calitate: diferența dintre "răspuns util" și "răspuns greșit jenant".
Recuperarea CRM — problema mai grea
Accesul CRM s-a dovedit a fi locul unde a mers majoritatea ingineriei. Trei motive:
Autorizarea este per-utilizator. Vederea fiecărui angajat asupra CRM-ului este diferită. Asistentul AI nu poate rula cu un cont de serviciu — trebuie să transmită propriile credențiale ale utilizatorului către Opal ERP, astfel încât ce poate vedea utilizatorul în asistent să se potrivească exact cu ce poate vedea în ERP. Am construit un strat de forwarding de bearer token per-request care propagă token-ul de sesiune al utilizatorului prin apelul de tool LLM în request-ul REST din amonte.
Designul tool-urilor contează mai mult decât inteligența modelului. Am expus cinci endpoint-uri Opal către Claude ca tool-uri — findProducts, findCustomers, findOrders, findInvoices, findOpportunities. Fiecare tool are o schemă strânsă (parametri specifici de filtru, răspunsuri paginate, limite maxime de rezultate). Prima versiune avea generic queryOpal(endpoint, params) și modelul a început imediat să construiască URL-uri de endpoint greșite. Strângerea suprafeței de tool la operațiuni de nivel agregat a rezolvat-o.
Formatarea rezultatelor alimentează înapoi în riscul de halucinație. Când un tool CRM returnează 50 de câmpuri per rezultat, modelul se distrage și halucinează câmpuri. Am învățat să proiectăm rezultatele jos la cele 5-10 câmpuri pe care utilizatorul le-a cerut efectiv, înainte să ajungă vreodată la LLM. Mai puțin context, mai multă acuratețe.
Orchestrarea multi-hop — unde devine interesant
Pentru query-uri hibride ("politică + date live"), orchestrarea rulează:
- Clasificatorul decide "hibrid".
- Query-ul este descompus într-o parte de document și o parte CRM de către un apel LLM de planificare.
- Recuperarea documentelor și recuperarea CRM rulează în paralel.
- Ambele seturi de rezultate sunt transmise lui Claude cu un prompt de sinteză: "Răspunde la întrebarea utilizatorului folosind aceste documente și aceste date CRM. Citează fiecare afirmație cu sursa. Nu inventa câmpuri care nu sunt prezente în date."
- Răspunsul se streamează înapoi la utilizator cu marcaje de citare inline.
Două pattern-uri pe care le-am încercat și abandonat:
- Planificare secvențială ("mai întâi recuperează documente, apoi decide dacă este nevoie de CRM"). Prea lent; dublează latența pentru query-uri hibride.
- Self-asking ("modelul generează recuperări de urmărire la nevoie"). Halucina recuperări frecvent. Modelul inventa un tool
findReturnPolicycare nu exista.
Recuperarea paralelă cu descompunere în avans a fost compromisul corect pentru cazul nostru de utilizare.
Citări — stratul de încredere
Fiecare afirmație în fiecare răspuns este ancorată fie într-un chunk de document, fie într-o înregistrare CRM. Frontend-ul redă marcaje de citare inline ([1], [2]) care se extind în sursa efectivă. Angajații pot verifica răspunsul în două click-uri și o fac — încrederea internă în asistent a venit din citări la fel de mult ca din calitatea răspunsului.
Implementare: LLM-ul este promptat să emită citări într-un format specific. Backend-ul parsează citările din răspunsul streamuit și le atașează la mesajul orientat către utilizator. Sursele pe care modelul pretinde că le citează dar care nu pot fi legate înapoi de chunk-uri recuperate sunt semnalizate în log-uri (citările halucinate se întâmplă; trebuie să știi).
Ce măsurăm
Metricile care contează în producție nu sunt metricile RAG academice:
- Acuratețea citărilor — fracțiunea de afirmații care se potrivesc cu sursa citată. Eșantion manual, săptămânal. Țintă: >95%.
- Time to first token — sub 1.5s pentru single-hop, sub 3s pentru hibrid.
- Rata de succes a apelurilor de tool — fracțiunea de apeluri de tool CRM care returnează un rezultat non-gol. O rată scăzută înseamnă că clasificatorul sau construcția query-ului este stricată.
- Rata de citări halucinate — citări care indică către chunk-uri recuperate, dar fac afirmații nesusținute de ele. Urmărită, alertată.
- Rata de "răspuns greșit" semnalată de utilizator — singura metrică care contează în cele din urmă.
Nu urmărim un singur număr de benchmark. Urmărim toate cele cinci și acționăm atunci când oricare se degradează.
Ce s-a rupt (și cum am reparat)
Câteva povești de război:
- Problema tokenizer-ului român. Versiunile timpurii chunkificau documente românești folosind un tokenizer care număra greșit diacriticele, producând chunk-uri de lungime sălbatic variată. Calitatea recuperării s-a prăbușit. Reparat prin trecerea la un tokenizer cu gestionare Unicode corespunzătoare.
- Cascada de expirare a credențialelor CRM. Când token-ul de sesiune al unui utilizator expira în mijlocul unei conversații, fiecare apel de tool CRM începea să eșueze tăcut. Modelul "infera" util răspunsuri. Reparat cu refresh proactiv de token + afișare explicită a eșecurilor de tool în prompt.
- Timeout-ul rerankerului sub încărcare. Microserviciul Python de reranker era single-threaded implicit și a început să facă timeout când 10+ utilizatori făceau query simultan. Reparat prin adăugarea unei cozi de cereri cu limite de concurență corespunzătoare.
Niciuna dintre acestea nu a fost prezisă în faza de design. Toate au apărut în primele două săptămâni de trafic real al utilizatorilor.
Ce am face diferit
Dacă am începe acest build astăzi (mai 2026):
- Sari peste planificatorul de query făcut în casă. Am folosi un framework precum LangGraph sau DSPy pentru stratul de orchestrare în loc să-l rulăm singuri. Versiunea făcută în casă funcționează, dar costul de mentenanță este real.
- Investește mai devreme în infrastructura de evaluare. Am măsurat manual prea mult timp. Un mic harness de evaluare făcut în casă cu 100 de query-uri de referință și răspunsuri notate s-ar fi plătit până în luna a doua.
- Planifică autorizarea tool-urilor din prima zi. Forwardingul de credențiale per-utilizator a fost retrofit-at. Ar fi trebuit să fie o constrângere de design portantă din primul sprint.
Imaginea mai mare
Multi-hop RAG nu este o funcționalitate. Este diferența dintre "asistent AI de grad de demo" și "asistent AI de producție pentru un business". Complexitatea adăugată — clasificare de query, recuperare paralelă, ancorare prin citări, autorizare a tool-urilor, reranking — este ceea ce transformă "drăguț" în "demn de încredere".
Echipa Teilor folosește Opal KB în fiecare zi. Cel mai comun feedback nu este "acesta este AI impresionant" — este "am găsit răspunsul în 10 secunde în loc de 20 de minute". Asta este ceea ce arată RAG-ul de producție când funcționează. Plictisitor, rapid, citat și liniștit indispensabil.
Dacă construiești un sistem RAG în 2026 și vezi demo-uri impresionante care se prăbușesc la întrebări reale, golul este aproape sigur orchestrarea multi-hop. Pattern-urile de mai sus nu sunt noi; sunt doar ce este nevoie pentru a ajunge acolo.