CAI Technology
Menu ☰
rag · · 13 min citire

Citation grounding: implementare pipeline cu 4 porți

Pipeline practic de citation grounding pentru RAG juridic și de procurement: retrieve, answer cu citări, validate, return. Cu pseudocode complet.

CAI Technology · Ultima revizuire: 30.04.2026
Citation grounding: implementare pipeline cu 4 porți

Citation grounding implementat practic: pipeline cu 4 porți pentru RAG juridic

Diferența dintre un RAG demo și un RAG production-ready pentru sectoare reglementate este o singură capabilitate: garanția că fiecare afirmație factuală din răspuns este ancorată într-un fragment textual identic dintr-un document sursă verificabil. Această capabilitate se numește citation grounding.

Acest articol descrie pipeline-ul practic cu 4 porți pe care îl folosim în Leta și în implementări similare pentru clienți. Include pseudocode, edge cases, și măsurători reale pe un dataset de 1.200 întrebări juridice românești.

TL;DR

De ce textuală, nu semantică

O greșeală comună: implementatorii folosesc un al doilea LLM pentru a verifica dacă citatul „seamănă” cu sursa. Acea validare este semantică. Funcționează în 95% din cazuri, dar eșuează exact pe cazurile critice: când modelul fabrică un citat care „arată” ca textul oficial dar conține modificări subtile (înlocuirea unui număr, schimbarea unui cuvânt cheie).

Validarea textuală — căutarea exactă a citatului în documentul sursă — elimină această clasă de eroare. Cere mai multă disciplină în prompt (modelul trebuie să citeze verbatim, nu să parafrazeze), dar oferă o garanție pe care validarea semantică nu o poate da.

Arhitectura cu 4 porți

Query


[Poartă 1: Retrieve cu metadata]
  → top-k fragmente + document_id + offset_start + offset_end


[Poartă 2: Generate cu format strict de citare]
  → răspuns + lista (claim_id, document_id, citat_textual, offset_estimate)


[Poartă 3: Validate textual]
  → pentru fiecare claim, verifică citat in document
  → dacă fail: regenerate cu prompt mai strict (max 3 încercări)


[Poartă 4: Return]
  → răspuns cu citări active (linkuri la fragmente exacte) SAU
  → refuz controlat ("nu am sursă suficientă pentru ...")

Poarta 1 — Retrieve cu metadata

Spre deosebire de un RAG simplu care returnează doar text și similarity score, în acest pipeline fiecare fragment trebuie să poarte metadata necesare pentru validare:

def retrieve(query: str, top_k: int = 20) -> list[Fragment]:
    embeddings_query = encoder.encode(query)
    candidates = vector_db.search(embeddings_query, k=top_k * 3)
    bm25_results = bm25_index.search(query, k=top_k * 3)
    fused = rrf_fuse(candidates, bm25_results, k=top_k)
    return [
        Fragment(
            text=f.text,
            document_id=f.document_id,        # critic pentru validare
            offset_start=f.offset_start,      # critic pentru validare
            offset_end=f.offset_end,
            score=f.score,
            source_url=f.source_url,
        )
        for f in fused
    ]

Fragmentele trebuie indexate cu offset-uri în documentul original, nu doar cu text. Aceasta este cea mai costisitoare schimbare față de un RAG simplu: pipeline-ul de ingest trebuie să păstreze poziția fiecărui chunk în document original.

Poarta 2 — Generate cu format strict de citare

Prompt-ul trebuie să forțeze modelul să citeze textual și să marcheze fiecare claim cu un identificator. Iată un prompt template care funcționează în producție:

Sistemul: Ești un asistent juridic care răspunde STRICT pe baza fragmentelor furnizate.

REGULI:
1. Fiecare afirmație factuală TREBUIE să fie marcată cu [c1], [c2], etc.
2. La sfârșit, listează citările în format JSON:
   [{"claim_id": "c1", "document_id": "...", "verbatim_quote": "..."}]
3. verbatim_quote TREBUIE să fie copiat exact din fragmentul indicat, fără parafrază.
4. Dacă nu poți răspunde pe baza fragmentelor, returnează: {"answer": null, "reason": "no_source"}

Fragmente disponibile:
[1] document_id=DOC_123: "În cazul în care contractul individual de muncă..."
[2] document_id=DOC_456: "Concedierea pentru motive imputabile salariatului..."
...

Întrebare utilizator: {query}

Modelul produce ceva de forma:

{
  "answer": "Concedierea disciplinară este permisă conform Codului Muncii [c1]. Procedura cere notificare scrisă în 30 zile [c2].",
  "citations": [
    {"claim_id": "c1", "document_id": "DOC_456", "verbatim_quote": "Concedierea pentru motive imputabile salariatului..."},
    {"claim_id": "c2", "document_id": "DOC_456", "verbatim_quote": "termenul de aplicare a sancțiunii disciplinare este de 30 zile..."}
  ]
}

Poarta 3 — Validate textual

Aici se întâmplă magia. Pentru fiecare citation, validăm că verbatim_quote apare efectiv în document_id:

def validate_citations(answer: dict, fragments: list[Fragment]) -> ValidationResult:
    fragment_by_doc = {f.document_id: f for f in fragments}
    failed = []
    
    for citation in answer["citations"]:
        doc_id = citation["document_id"]
        quote = citation["verbatim_quote"]
        
        if doc_id not in fragment_by_doc:
            failed.append((citation["claim_id"], "doc_not_in_retrieved"))
            continue
        
        source_text = fragment_by_doc[doc_id].text
        
        # Validare exactă (case-insensitive, dar diacritice respectate)
        if quote.lower() in source_text.lower():
            continue
        
        # Validare cu fuzzy match dacă diferența e doar whitespace/punctuație
        if normalize_text(quote) in normalize_text(source_text):
            continue
        
        # Validare cu similarity threshold ÎNALT (>= 0.95) pentru OCR-induced noise
        if text_similarity(quote, source_text) >= 0.95:
            continue
        
        failed.append((citation["claim_id"], "quote_not_found"))
    
    return ValidationResult(
        passed=len(failed) == 0,
        failed_claims=failed,
    )

Threshold-ul de 0,95 nu este pentru semantică, ci pentru noise tehnic (caractere OCR confuse, whitespace, ligaturi). Sub 0,95 considerăm că modelul a parafrazat, ceea ce nu este acceptabil.

Pipeline complet cu retry

def answer_with_grounding(query: str) -> Response:
    fragments = retrieve(query, top_k=20)
    
    if not fragments:
        return Response.refusal("no_relevant_sources")
    
    for attempt in range(3):
        answer = generate(query, fragments, strictness=attempt)
        validation = validate_citations(answer, fragments)
        
        if validation.passed:
            return Response.success(answer, fragments)
        
        # Regenerate cu prompt mai strict în care claims-urile failed sunt marcate
        # ca exemple de ce să NU faci
    
    # După 3 eșecuri, refuz
    return Response.refusal("could_not_ground", failed_claims=validation.failed_claims)

strictness crește la fiecare retry: prima încercare folosește prompt standard, a doua adaugă exemple negative, a treia restrânge la claims minimal posibile.

Poarta 4 — Return controlat

Răspunsul final trebuie să poarte cu el citările active. Pe UI, fiecare claim are un link la fragmentul exact, iar utilizatorul poate vedea contextul complet în 1 click. Această disciplină este esențială pentru avocați și auditori: ei nu acceptă „trust me” pe răspuns, dar acceptă „here is the source, see for yourself”.

Pentru cazurile de refuz, mesajul trebuie să fie acționabil:

"Nu am o sursă suficient de exactă pentru a răspunde la această întrebare.
Sugestie: reformulați cu termeni mai specifici sau consultați direct
[link la rezultate căutare brute pentru query]."

Edge cases critice

Claims agregate care violează multiple surse: „Codul muncii prevede concediere în 30 zile pentru motive disciplinare”. Aceasta combină informație din articol diferit. Soluția: forțați modelul să separe claims-urile granulare.

Citate care se suprapun parțial: două citate care folosesc fraze comune. Soluția: indexare cu offset-uri exacte, nu fuzzy matching pe document complet.

Documente abrogate: legislația se modifică. O citare validă astăzi poate fi din document abrogat acum 6 luni. Soluția: metadata valid_from / valid_until pe fiecare fragment, filter în retrieve.

Citate cu redactări/anonimizări: jurisprudența anonimizată conține [NUMELE PĂRȚII]. Modelul trebuie instruit să păstreze acele markeri exact.

Costuri și beneficii măsurate

Pe un benchmark intern de 1.200 întrebări juridice românești:

Costul în tokens este aproximativ 2,3× mai mare per query (datorită retry-urilor și prompt-ului mai lung). Pentru sectoare reglementate, această taxă este acceptabilă; pentru chatbot generic, nu se justifică.

Diagramă pipeline

Query
  → Retrieve (vector + BM25 + RRF, top-20 cu offset metadata)
  → Generate (prompt cu format JSON citation)
  → Validate (verbatim match >= 0.95, doc_id în set retrieved)
  → if pass: Return cu citări active
  → if fail: Regenerate (max 3 tries) cu strictness crescut
  → if 3 fails: Refuz controlat cu acțiune sugerată

Concluzie operațională

Citation grounding nu este o tehnologie. Este o disciplină de inginerie care reorganizează pipeline-ul RAG în jurul unei garanții: niciun token de răspuns substantiv nu pleacă fără sursă verificabilă textual. Costul este real (latency, tokens, complexitate). Beneficiul este nemonetizabil pentru sectoare unde halucinarea înseamnă răspundere profesională sau juridică.

Pentru clienți CAI Technology pe vertical juridic și procurement, acest pipeline este standard. Pentru implementare pe corpusul propriu, oferim consultanță tehnică structurată pe 4–8 săptămâni.

Articole conexe

Surse externe

Următorul pas

Dacă echipa dumneavoastră evaluează un pipeline de citation grounding pe corpus propriu, putem oferi o sesiune tehnică de 30 de minute pentru estimare de fezabilitate.

Începem cu o conversație de 30 de minute.

Audit AI-readiness gratuit pentru companii peste 50 angajați. Răspundem în 24 de ore.