Matteo
Vincenti
Menu

RAG in azienda: perché trova il documento sbagliato

Un laboratorio riproducibile confronta full-text, embedding e ricerca ibrida: quando correggere acquisizione, permessi o ranking.

Un dispositivo verde con griglia di ventilazione tra due manuali aperti: uno mostra il comando, l’altro la griglia aperta.

Un assistente aziendale riceve una domanda: «Come fermo il surriscaldamento di AX-42 senza spegnere l'unità?». Il primo documento trovato parla di arresto d'emergenza in presenza di fumo. La procedura per ridurre il carico e liberare la presa d'aria arriva seconda. Entrambi parlano dello stesso prodotto e della temperatura; servono a prendere decisioni diverse.

Questo passaggio avviene prima della risposta. Un sistema RAG, da Retrieval-Augmented Generation, cerca informazioni in una raccolta esterna e le mette a disposizione del modello che genera il testo. Se il recupero perde la fonte necessaria, il generatore parte da materiale incompleto. Se la fonte c'è, resta da verificare come viene usata. Il lavoro di Lewis e colleghi combina proprio generazione e memoria esterna recuperabile.

Per capire dove intervenire partirei dalle fonti attese per una domanda, prima di cambiare modello. Nel laboratorio sintetico che accompagna questa analisi, il full-text trova già tutte le evidenze richieste nei sette casi rispondibili entro i primi tre risultati. Ricerca semantica e ibrida cambiano alcune posizioni, senza aggiungere copertura. Il caso del contratto mancante, invece, fallisce con tutti i metodi.

Che cosa deve arrivare alla risposta

Un documento pertinente può non bastare. Per decidere se il cliente North può chiedere la sostituzione di un AX-42 acquistato venti giorni prima, servono la finestra prevista dalla policy e la copertura del contratto North. Recuperare soltanto la policy dei trenta giorni lascia aperta una parte della decisione.

Il laboratorio usa due identificativi di evidenza:

json
{
  "query_id": "Q07",
  "required_evidence_ids": [
    "returns-v2#window",
    "north-contract-v1#coverage"
  ]
}

La parte prima di # identifica una revisione del documento; quella dopo identifica il passaggio. È il formato di questo esempio. Gli attesi vengono scritti prima del confronto e letti dal valutatore dopo la ricerca. Il motore non li consulta per scegliere i risultati o dichiarare di non sapere.

Questo elenco rende controllabile una domanda precisa: sono arrivate entrambe le evidenze? Valutare soltanto la pertinenza dei frammenti recuperati lascerebbe inosservato il contratto rimasto fuori. Il rapporto distingue perciò quante evidenze sono arrivate dalla presenza dell'insieme completo.

Per costruire il primo set di valutazione sceglierei richieste su cui il team sa indicare i documenti necessari e quelle per cui la raccolta non contiene una risposta. Aggiungerei le formulazioni brevi e i codici usati dalle persone, oltre alle domande ben scritte del prototipo. Il lavoro iniziale è concordare che cosa costituisca una risposta supportata.

Quali fonti può usare quella richiesta

Nella raccolta convivono AX-42 e AX-24, due revisioni della scheda tecnica, una policy dei resi precedente e una corrente, un documento riservato alle operations. La similarità con la domanda non decide quale versione sia efficace o quali permessi abbia chi chiede.

Tutti i metodi applicano gli stessi controlli prima del ranking:

  • prodotto esplicito nella domanda;
  • documento pubblicato, efficace alla data richiesta;
  • almeno un gruppo autorizzato in comune con l'identità della fixture.

La policy precedente resta corretta per una domanda sul primo febbraio 2026. Un filtro che ammettesse soltanto la versione corrente renderebbe impossibile quella ricerca storica. Serve quindi una data di riferimento, con un intervallo di validità definito; la data di caricamento del file non svolge lo stesso lavoro.

Nel codice, la selezione temporale e dei gruppi è questa:

sql
WHERE d.product_id = %s
  AND d.status = 'published'
  AND d.allowed_groups && %s::text[]
  AND d.valid_from <= %s::date
  AND (d.valid_to IS NULL OR %s::date < d.valid_to)

I parametri vengono passati da Python al database. Le date iniziali sono incluse, quelle finali escluse. I gruppi arrivano da una configurazione fidata del test: un servizio reale deve ricavarli dall'identità autenticata e mantenere aggiornati i metadati. La documentazione Microsoft sui security filters mostra lo stesso principio di applicare il filtro a ogni query. Nascondere il nome del gruppo nell'output non impone un'autorizzazione.

Anche il punto in cui si filtra conta. Cercare prima cinque risultati nell'intero archivio e scartarne poi quattro può lasciare fuori documenti autorizzati che erano più in basso. Microsoft documenta questo rischio nelle proprie modalità di filtro vettoriale. Il laboratorio seleziona l'insieme ammissibile prima di calcolare le liste e usa una ricerca esatta su pochi vettori.

Il documento delle eccezioni interne viene così escluso per sales e ammesso per ops. Questo è un risultato del controllo comune. Non dimostra che il full-text o l'encoder riconoscano meglio i permessi. Lo stesso vale per AX-24, eliminato dal filtro di prodotto.

Confrontare il ranking sullo stesso materiale

Ogni sezione diventa un chunk, cioè un passaggio indicizzato. Il testo cercato contiene titolo del documento, intestazione della sezione e corpo. Il riferimento al documento conserva la revisione: rack-guide-v2#airflow non viene ricongiunto a una guida precedente. Queste informazioni sono uguali per tutti i metodi.

Il full-text di PostgreSQL riduce le parole a lessemi, usa la configurazione inglese per stemming e stop word, poi ordina con ts_rank_cd. La query combina i lessemi con OR: richiedere tutte le parole di una domanda naturale avrebbe costruito un confronto inutilmente debole. È una baseline lessicale dichiarata, diversa da BM25. La documentazione PostgreSQL descrive parser, configurazione e ranking, ricordando che la rilevanza dipende dall'applicazione.

La ricerca semantica, detta anche dense retrieval, calcola un embedding: un vettore numerico prodotto da un modello a partire dal testo. Query e passaggi entrano nello stesso spazio; il codice ordina la similarità coseno. Qui l'encoder è il modello pubblico all-MiniLM-L6-v2, con 384 dimensioni, eseguito tramite il suo artefatto ONNX su CPU. Tokenizer, pooling medio con attention mask e normalizzazione seguono i file del modello. I testi oltre 256 wordpiece causano un errore esplicito.

La terza variante fonde le liste lessicale e semantica con Reciprocal Rank Fusion, RRF. Somma contributi basati sulla posizione, senza sommare direttamente punteggi che hanno scale diverse:

py
score[evidence_id] += 1 / (60 + position)

La costante è 60, il pool massimo è 20, i controlli riguardano i primi tre e cinque risultati. Questi valori, le query e gli attesi sono stati fissati prima di osservare l'output. Il codice prodotto viene tolto dalla query di ranking dopo il filtro comune, per tutti i metodi: l'identificativo ha già selezionato il prodotto.

Che cosa cambia nei dieci casi

La tabella conta le evidenze richieste trovate entro i primi tre risultati. Fra parentesi è indicato il rango quando serve a capire la differenza. «—» significa che per quella richiesta non esiste evidenza ammessa nella fixture; recall e completezza restano nulli.

Che cosa cambia nei dieci casi
CasoFull-textDenseRRF
Q01 — temperatura massima AX-421/1, rango 11/1, rango 11/1, rango 1
Q02 — raffreddare AX-42 mantenendolo acceso1/1, rango 21/1, rango 11/1, rango 1
Q03 — finestra corrente di sostituzione1/1, rango 11/1, rango 11/1, rango 1
Q04 — finestra al 1 febbraio 20261/1, rango 21/1, rango 31/1, rango 3
Q05 — eccezione interna richiesta da sales———
Q06 — stessa richiesta da ops1/1, rango 11/1, rango 11/1, rango 1
Q07 — policy e contratto North2/22/22/2
Q08 — contratto North non acquisito1/21/21/2
Q09 — pressione subacquea non documentata———
Q10 — flusso d'aria per il rack1/1, rango 11/1, rango 11/1, rango 1

Con cinque risultati la copertura rimane identica. Nei sette casi rispondibili tutti i metodi hanno tutte le fonti; nel caso con acquisizione incompleta manca sempre il contratto.

La domanda sul raffreddamento mostra perché guardare anche l'ordine. Il full-text mette prima l'arresto d'emergenza per fumo e poi la procedura che mantiene l'unità in funzione. Dense e RRF portano quest'ultima al primo posto. Con k=3 la fonte utile era già disponibile; il suo utilizzo da parte di un generatore rimane una verifica distinta.

La domanda storica mostra il movimento opposto: la policy necessaria scende dal secondo al terzo posto. Aggiungere un segnale semantico può riordinare bene una domanda e male un'altra. Per il requisito «tutte le evidenze nei primi tre», su questo set terrei il full-text e approfondirei l'ambiguità delle domande prima di aggiungere l'encoder.

Q08 richiede un intervento diverso. Il confronto ripete la domanda North dopo aver escluso il contratto dall'acquisizione. La policy corrente viene trovata, ma nessun metodo può recuperare un documento assente dall'indice. Il rapporto del valutatore mostra esattamente quale identificativo manca.

Q09, invece, chiede una pressione subacquea mai dichiarata nei documenti. Tutti i metodi mettono al primo posto un passaggio sulla pressione del tubo usato per la manutenzione. Il numero è vicino al tema e non risponde alla domanda. Un motore che restituisce vicini non ha per questo dimostrato che esista una risposta. Il paper UAEval4RAG studia la necessità di valutare anche richieste senza risposta; qui l'insieme delle evidenze attese rende visibile l'assenza di supporto, senza trasformarsi in una regola di astensione a runtime.

Non è entrato alcun candidato o contesto escluso dai controlli comuni di prodotto, gruppi, pubblicazione o validità. Il titolo e la sezione sono sempre presenti: il confronto non misura un vantaggio dell'aggiunta del contesto del documento. La ricerca di Anthropic sul Contextual Retrieval motiva quel problema quando i passaggi perdono riferimenti utili; i suoi risultati riguardano un'altra tecnica e un altro setup.

Riprodurre il confronto in Docker

Il pacchetto riproducibile «rag-evidence-lab» (create_lab.py) offre un unico generatore completo per tutti i diciotto file. Salva il blocco Python della pagina come create_lab.py in una nuova cartella di lavoro ed esegui:

bash
python3 create_lab.py
cd rag-evidence-lab

Lo script controlla il payload e gli hash di ogni file prima di creare la directory; rifiuta una directory rag-evidence-lab già esistente. I singoli sorgenti nella pagina servono per l'ispezione: il generatore li scrive tutti. Il README e i comandi seguenti guidano la preparazione del workspace e l'esecuzione.

Servono un Docker Engine Linux ARM64 attivo, il comando docker compose e Python 3.10 o successivo già disponibile sull'host per preparazione e hash. Il run è stato eseguito con Engine 29.9.2 e Compose 5.6.1 su ARM64. Il lock include wheel Linux ARM64: su AMD64 occorre preparare un piano separato, senza assumere che l'emulazione abbia gli stessi costi.

Il runner ha un limite di due CPU e 4 GiB; PostgreSQL ha 512 MiB. Riserva 5 GiB di disco. I file del piano fissato pesano 145.987.274 byte, circa 139,2 MiB, oltre alle immagini base. Corpus e query restano in inglese anche in questa versione dell'articolo, perché il modello scelto è inglese. Il test non valuta la traduzione delle query.

Questi sono i ruoli dei file che userai:

Riprodurre il confronto in Docker
File o gruppoChe cosa fornisce
fixtures/documents.jsonlDocumenti, revisioni, validità, gruppi e sezioni
fixtures/queries.jsonl e expected.jsonlInput delle richieste e attesi separati per il valutatore
config.json, artifact-plan.json, requirements.lockParametri, URL/dimensioni/hash degli artefatti e dipendenze CPU fissate
compose.yaml, Dockerfile, versions.env.exampleServizi, rete interna e immagini fissate
prepare_workspace.py, download_model.pyPreparazione della directory di lavoro e download verificato
encoder.py, schema.sql, lab.py, retrieval.py, evaluate.pyEncoding, indicizzazione, ricerca e confronto post-hoc

Scegli un percorso nuovo per il workspace. Il preparatore rifiuta una directory già esistente:

bash
export RAG_WORK=/tmp/rag-evidence-lab-run
python3 prepare_workspace.py "$RAG_WORK"
docker version
docker compose version
docker compose --env-file "$RAG_WORK/versions.env" -p rag-lab config --quiet
docker compose --env-file "$RAG_WORK/versions.env" -p rag-lab run --rm model-download

Il downloader controlla dimensioni e digest, poi crea wheel, cache del modello e ricevuta dei download nel workspace. Usa soltanto URL pubblici fissati, senza token. Non eseguire metadata_plan.py per riprodurre questo run: è lo strumento opzionale per preparare un nuovo piano, che risolverebbe versioni correnti differenti.

Costruisci ora il runner offline e avvia il database:

bash
DOCKER_BUILDKIT=0 docker build --rm --force-rm --no-cache --network none \
  --build-arg PYTHON_IMAGE=python@sha256:c3729439afc4ec3f3e159e0c86aeabfadac8030ef46838a0256383d0511c5f5e \
  -t rag-evidence-lab:20261024-onnx-cpu "$RAG_WORK"
docker compose --env-file "$RAG_WORK/versions.env" -p rag-lab up -d --wait db
python3 lab.py freeze
docker compose --env-file "$RAG_WORK/versions.env" -p rag-lab run --rm runner python lab.py run
docker compose --env-file "$RAG_WORK/versions.env" -p rag-lab run --rm runner python evaluate.py

La disattivazione di BuildKit usa il builder legacy provato per questo pacchetto ed evita di lasciare cache di build condivisa. Può comparire un avviso di deprecazione. Se il motore non lo supporta, serve definire un altro percorso di build e pulizia prima di eseguire il test.

Il servizio di download ha accesso alla rete esterna; runner e database comunicano soltanto sulla rete Compose interna, senza porte esposte sull'host. Non vengono installati pacchetti sull'host. La generazione delle risposte non fa parte del flusso.

Il comando freeze registra gli hash di codice, configurazione e fixture. Il run rifiuta un freeze cambiato o un risultato già esistente. Se una correzione richiede un nuovo tentativo, conserva prima la ricevuta precedente e prepara un nuovo workspace.

Alla fine troverai:

  • output/retrieval.json: liste ordinate, contesti, lessemi della query e ambiente;
  • output/evaluation.json: evidenze attese, trovate, mancanti ed eventualmente escluse, per caso e k;
  • output/document-embeddings.npy: i vettori realmente prodotti dal modello.

L'esito da controllare è la tabella per caso, insieme all'assenza di candidati vietati. Nei casi senza evidenza attesa i valori restano nulli; nei casi incompleti il rapporto conserva l'identificativo mancante. Un singolo «test passato» cancellerebbe queste differenze.

Prima della pulizia conserva ciò che serve al confronto. Poi rimuovi i servizi e le reti di questo progetto:

bash
docker compose --env-file "$RAG_WORK/versions.env" -p rag-lab down --volumes --remove-orphans
docker image rm rag-evidence-lab:20261024-onnx-cpu

Rimuovi il workspace scelto dopo aver salvato le ricevute. Le immagini base possono essere condivise con altri lavori: controllane l'uso prima di eliminarle. Il pacchetto non richiede un prune globale.

Decidere che cosa correggere

La prima domanda per il team è: la fonte necessaria esisteva, era stata indicizzata ed era ammessa per quella richiesta? Se manca il contratto, va corretta l'acquisizione. Se il documento appartiene a un'altra revisione o a un altro gruppo, va corretto il controllo di ammissibilità. Se la fonte valida rimane fuori dai risultati utili, ha senso confrontare query, chunking e ranking.

Ricerca lessicale e semantica offrono segnali diversi. I codici possono beneficiare di campi esatti; una parafrasi può beneficiare degli embedding. Il risultato utile è capire quali domande cambiano e quale requisito migliorano. In questo confronto l'ibrido non aggiunge evidenze entro k=3: introdurlo soltanto perché esiste sarebbe un costo di integrazione senza un beneficio di copertura osservato.

Se una parte delle informazioni vive in una wiki di contesto mantenuta dal team, conserva il collegamento alla fonte e una responsabilità di aggiornamento. Il recupero successivo deve poter distinguere quel riepilogo dal documento su cui si basa.

Un corpus ammesso piccolo e stabile può anche essere passato interamente al modello, valutando il riuso del contesto con cache. Il paper sulla Cache-Augmented Generation esplora questa scelta su raccolte gestibili. Per domande che dipendono da relazioni esplicite può servire confrontare un grafo o più passaggi di ricerca. Nessuna di queste scelte ricrea una fonte che non è stata acquisita.

La prova da chiedere prima di cambiare architettura è quindi un elenco leggibile: domanda, evidenze necessarie, fonti ammesse, passaggi effettivamente recuperati. È abbastanza concreto da mostrare se il prossimo lavoro riguarda i documenti, i controlli o il ranking.

Condividi l’articolo

Hai una domanda o un’osservazione sull’articolo?

Scrivimi su LinkedIn
Tutti gli articoli