Arhitectură internă · 15 august 2026

Cum ajunge o întrebare de la utilizator la un răspuns cu surse.

O singură clasă centrală, patru surse în baza de date, și tool-uri care fie o apelează, fie își fac căutarea pe cont propriu.

Sursă
repo Achizia · main · 6fd4359
Nucleu
backend/app/services/rag.py · 2810 linii
Citit și
chat.py, ragmemo.py, drafter.py, training, red flags, strategy, compliance
01

Vedere de ansamblu

Corpusul stă în PostgreSQL cu pgvector. RAG-ul nu „știe” tot ce e în platformă — caută în patru tabele cu embeddings Gemini, 2000 de dimensiuni, index HNSW.

CORPUS · PostgreSQL + pgvector · cosine / HNSW / 2000d PRINCIPAL argumentare_critica chunk = o critică extrasă de LLM nu o felie din textul deciziei LEGE legislatie_ fragmente art / alineat ANAP spete_anap interpretări oficiale INSTANȚE jurisprudenta_ instante CJUE / CCR / ÎCCJ PĂRINTE decizii_ cnsc doar metadate caută ↑ · chunk-uri + metadate ↓ INTRARE Utilizator întrebare / document întrebare MOTOR RAGService 0 embed o dată Gemini 2000d 1 lege / spețe / instanțe 2 search_ decisions 3–4 context + LLM răspuns + citații Asistent Achizia chat.py · flux SSE Memo Juridic ragmemo.py · analiză cu surse Drafter / Training multi-chunk pe documente

Nu există un agent autonom. „Agentul Achizia” este Asistentul — endpoint-ul de chat care apelează RAGService ca pe o bibliotecă. Nu alege singur între Memo, Red Flags sau Clarificări. Nu există tool-calling.

02

Ce se taie și ce se embeduiește

Chunk-ul din corpus nu e o fereastră din textul deciziei CNSC. E analiza LLM pe o singură critică. Textul brut al deciziei nu intră în vector.

INGESTIE — o dată, la import text_integral decizia brută analysis.py LLM rupe pe critici argumentare_critica un chunk = o critică embedding Gemini max 8.000 caractere LA ÎNTREBARE întrebare scurtă un singur vector document utilizator taie ~1.500 car. / bucată caută în criticile deja embeduite mai sus

Nu: textul deciziei

Nu se taie text_integral în ferestre de N caractere. Textul brut mai apare doar la fallback (articol de lege, keyword ILIKE) — nu pe calea principală.

De ce nuTextul brut e zgomotos — antete, tabele stricate. De-aia s-a renunțat la el pe calea principală.

Da: analiza pe critică

LLM-ul din analysis.py rupe decizia pe critici și umple argumentare_critica. Aia e unitatea de căutare.

Se embeduieștecod critică, argumente contestator / AC / intervenienți, ce a reținut CNSC, argumentația CNSC, jurisprudența invocată, cine a câștigat. Tăiat la 8.000 de caractere.
La generareContextul e tot din aceste câmpuri. text_integral nu se mai încarcă.

Alt chunking, mai jos. „Documente lungi” taie documentul utilizatorului (paragrafe de ~1.500 de caractere). Cu acele bucăți se caută tot în analiza pe critică, nu în textul deciziei.

03

Cascada de căutare

search_decisions încearcă strategiile în ordine și se oprește la primul succes. Apoi prepare_context adaugă lege, spețe și instanțe — totul pe aceeași conexiune, pe rând (asyncpg nu permite query-uri paralele).

Întrebare 1 Referință BO? BO2025_1011 da Lookup exact + toate criticile oprire nu 2 Articol de lege? art. 57 Legea 98/2016 da Fragmente + decizii asociate oprire nu 3 · PRINCIPAL Vector search + expand / rerank opțional găsit Dedup + sortare decizii oprire nimic 3b Fallback CPV găsit Decizii din același domeniu oprire nimic 4 Keyword ILIKE Ultima plasă, fără vector folosită rar Apoi, pe aceeași conexiune prepare_context adaugă, pe rând: 1 legislație — 5 fragmente 2 spețe ANAP — 3 3 instanțe — 3 4 critici CNSC — max 5 / 20 Nu în paralel: asyncpg nu lasă două query-uri pe o conexiune.

trece mai departeverde = s-a găsit, se oprește

1

Referință BO directă

Dacă întrebarea conține ceva de forma BO2025_1011, se deschide exact decizia aceea și toate criticile ei. Fără căutare semantică.

2

Articol de lege

„art. 57 din Legea 98/2016” → fragmentele legislative + deciziile CNSC care le aplică.

3

Căutare vectorială — calea principală

Întrebarea e transformată într-un vector Gemini (tăiată la 4.000 de caractere). Opțional: reformulare prin LLM, căutare trigram + fuziune RRF, reranking. Rerankingul pe model rapid durează ~3 secunde, nu 33.

expandare · off implicit rerank · opt-in, implicit pe model rapid dedup pe chunk
3b

Fallback pe domeniu CPV

Dacă vectorul n-a găsit nimic, se potrivește întrebarea cu nomenclatorul CPV și se aduc decizii din același domeniu.

4

Cuvinte-cheie

Ultima plasă: căutare ILIKE pe text. Folosită rar, când restul e gol.

Ce se adună în context, după căutare

PasSursăCât
1legislatie_fragmente5 fragmente
2spete_anap3 spețe
3jurisprudenta_instante3 decizii de instanță
4argumentare_critica → decizii CNSCmax 5 decizii / 20 chunk-uri
+statistici de corpusdoar la întrebări de inventar („câte decizii aveți?”)

Din astea se construiesc: blocuri de context etichetate după forța juridică (lege, instrucțiune, îndrumare, sinteză ANAP, abrogat), un system prompt dinamic, citații în ordinea lege → instanțe → ANAP → CNSC (UI-ul arată doar primele N) și un scor de încredere. Generarea: temperatură 0,1, până la 12.288 de tokeni. Persona din prompt este tăiată din răspuns.

04

Cine folosește RAG-ul

Patru module trec prin RAGService. Alte cinci își fac embeddings și caută singure — aceeași bază de date, alt drum.

Prin RAGService

Același motor, același system prompt, aceleași citații.

Asistent AchiziaChat cu flux. Mesajele mari și atașamentele trec pe multi-chunk. Memoria utilizatorului se adaugă în prompt. Păstrează conexiunea deschisă cât gândește.
Memo JuridicO analiză cu surse pe o problemă juridică. Timeout mai lung. Temele lungi → multi-chunk.
DrafterCaută în toate documentele deodată, ca să acopere toate argumentele. Leagă fiecare critică CNSC de articolul de lege citat.
TrainingAPMix: leagă criticile de articole + caută direct în legislație (prag de distanță 0,6).

Doar embeddings

Nu trec prin RAGService. Iau vectorul și interoghează tabelele lor.

StrategyStatistici + decizii similare pe caz.
ComplianceFragmente de lege pentru fiecare clauză din document.
Red FlagsMai întâi detectează clauzele, apoi le ancorează în lege.
ClarificăriO clauză (max. 3.000 de caractere) → fragmente relevante.
BibliotecaCăutare semantică directă în catalogul CNSC.
05

Documente lungi

Aici se taie documentul utilizatorului, nu decizia CNSC. Când întrebarea e un fișier, un singur embedding pierde detalii. Asistentul, Memo Juridic și Drafterul taie textul și caută — tot în analiza pe critică.

01

Unește

Toate documentele într-un singur text.

02

Taie

Paragrafe peste 100 de caractere, grupate în bucăți de ~1.500.

03

Caută

Embedding + vector search pe fiecare bucată, pe rând.

04

Curăță

Păstrează cea mai bună distanță pentru fiecare critică.

05

Limitează

Maximum 15 decizii și 8 fragmente de lege.

06

De unde vin embeddings-urile

Decizia se importă, se parsează, apoi LLM-ul extrage criticile. Abia acele critici primesc vector. Pipeline-ul scrie în Postgres; fișierele sursă stau în GCS.

Decizii CNSC

  1. GCS → import
  2. Parser regex, fără LLM
  3. Analiză LLM: critici, rezumat, CPV

Lege și spețe ANAP

  1. Portale oficiale
  2. Import legislație pe alineat
  3. Import spețe ANAP
generate_embeddings.py → vector(2000), Gemini, index HNSW

Dacă dimensiunea coloanei nu e 2000, serviciul o recreează singur.

07

Ce merită discutat

Observații din citirea codului, nu un plan de execuție.

  1. 1

    Asistentul nu e agent

    Dacă ar trebui să aleagă el între Memo, Red Flags și Clarificări, e un feature nou. Acum fiecare instrument e un endpoint separat, cu pipeline-ul lui.

  2. 2

    Patru module caută pe cont propriu

    Strategy, Compliance, Red Flags și Clarificări duplică praguri și logică de retrieval. Dacă apar diferențe de calitate între ele, primul candidat e un API comun de căutare.

  3. 3

    Rerankingul e fezabil

    Era oprit din cauza latenței. Pe model rapid a scăzut de la 33s la 3s. Expandarea rămâne opt-in.

  4. 4

    Instanțele sunt subțiri

    jurisprudenta_instante are prioritate în citare și în prompt, dar doar 3 rezultate și acoperire slabă. Forța juridică e mare, corpusul nu.