Il problema
Il RAG viene solitamente inquadrato come un problema runtime: l'utente fa una domanda, il sistema recupera i documenti, il modello risponde. È uno schema solido. Ma in un assistente di coding agentico — dove lo stesso team fa centinaia di sessioni a settimana contro un corpus tecnico in crescita — la domanda interessante si sposta a monte: come carichi la documentazione giusta nel contesto prima ancora che qualcuno la chieda?
Il contesto di partenza: un team di ingegneri lavora su un corpus di circa 400 documenti markdown che coprono integrazioni con servizi esterni, pipeline di calcolo, regole di business verticali e runbook operativi. Il corpus cresce ogni settimana. I nuovi arrivati non sanno dove si trova nulla. I veterani dimenticano i dettagli.
La soluzione naiva è un indice statico: un INDEX.md con una riga per file. Funziona per chi conosce il repo a memoria. Fallisce per:
- Query cross-lingua — il team fa domande in italiano, i documenti sono scritti in inglese.
- Query parafrasate — "come viene calcolato il prezzo di X" contro il titolo del doc "X Pricing Pipeline".
- Query di nuovi arrivati — chi non conosce il termine canonico non troverà nulla con grep.
- Agenti LLM — leggere
INDEX.mdcosta ~25k token per sessione e i sinonimi sfuggono comunque.
L'obiettivo è un sistema automatico: l'utente (umano o LLM) scrive un prompt, e i documenti rilevanti compaiono nel contesto senza che nessuno li abbia esplicitamente richiesti. Nessun comando manuale /doc-search, nessuna dipendenza dal fatto che l'agente si ricordi di cercare.
Architettura in tre livelli
Il sistema si compone di tre livelli disaccoppiati — più un quarto che chiude il loop in modo automatico.
Un quarto livello — un git post-commit hook — chiude il loop: ogni commit che tocca docs/*.md avvia il re-embedding dei file modificati in background, così l'indice non deriva mai dalla sorgente.
La trappola ivfflat: un bug silenzioso di recall
Il primo deploy in produzione usava ivfflat con lists=50. La documentazione dell'estensione suggerisce lists = righe/1000 come euristica; con ~12.000 chunk quella regola porta vicino a 12. Abbiamo messo 50 per stare sul sicuro, lasciato probes=1 (il default), e deployato.
Per le query topiche generiche tutto sembrava funzionare. Poi un giorno qualcuno ha fatto una domanda in italiano su un'entità ben documentata nel corpus: il sistema ha restituito una top-1 similarity di 0.42, e il documento autorevole non appariva nemmeno nella top-100. Calcolando manualmente la cosine similarity tra la query e ogni chunk con una query SQL diretta — bypassando l'indice — lo stesso documento tornava al rank 1 con similarità 0.63.
Il bug: con probes=1, ogni query visita il cluster di un singolo centroide su 50. I chunk che vivono nei cluster non visitati vengono silenziosamente saltati. Per il nostro corpus e i confini dei cluster, circa il 30% dei documenti era effettivamente invisibile al path di ricerca default. La query funzionava. L'indice mentiva.
Con lists elevato e probes=1, quote significative del corpus diventano inaccessibili. Il sistema non dà errori: restituisce risultati, semplicemente non quelli giusti. È la peggiore categoria di bug: silenzioso e apparentemente funzionante.
Fix: passare a HNSW.
-- Creare l'indice HNSW in sostituzione di ivfflat
CREATE INDEX idx_doc_chunks_embedding_hnsw
ON doc_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
HNSW offre recall quasi esatta al costo di una modesta latenza per query (osservata: ~2s cold, <500ms warm, su ~12.000 chunk su un'istanza Supabase condivisa). Per qualsiasi use case di knowledge base in cui un utente sta aspettando una risposta, il tradeoff è ovvio. Nel momento in cui probes=1 lascia un singolo documento orfano nello spazio dei centroidi, ivfflat diventa una trappola.
Questo solo cambiamento — senza toccare un documento o tarare una singola soglia — ha spostato il recall@3 da 0.61 a 0.96. Il resto del lavoro è stato prendere quegli ultimi 4 punti e prevenire la deriva futura.
Design dei documenti: la convenzione degli header
La ricerca vettoriale è buona quanto il contenuto che indicizza. Due problemi continuavano a emergere:
- Gap cross-lingua. I doc sono scritti in inglese (codice e API sono inglesi). Query italiane come “come funziona il recupero dati dal servizio esterno” ottenevano similarity 0.42; lo stesso concetto in inglese (“external service data retrieval”) otteneva 0.68 contro gli stessi chunk. Jina v3 è multilingua, ma con query colloquiali brevi contro passi tecnici lunghi il ponte semantico è debole.
- Cecità su acronimi e identificatori interni. Query che menzionano sigle o nomi di sistema non surfaceavano i doc che li trattavano, perché la prosa circostante parafrasava invece di ripetere quei token.
Invece di fare fine-tuning sul modello di embedding o deployare un reranker, abbiamo introdotto una convenzione di header Topic/Synonyms/See also in cima a ogni documento indicizzato:
# Elaborazione Dati da Fonte Esterna
> **Topic**: recupero dati, calcolo valore, integrazione API, normalizzazione
> **Synonyms**: data fetching, price resolution, value calculation | recupero dati, calcolo prezzo, risoluzione valore
> **See also**: integrazioni/fonte-primaria.md, pipeline/normalizzazione.md
Tre proprietà rendono efficace questo approccio:
- Front-loaded. L'header viene preposto a ogni chunk del documento prima dell'embedding. Ogni chunk eredita l'ancora semantica, non solo il chunk 0.
- Bilingue. Il separatore
|divide i termini canonici inglesi dalle parafrasi nella lingua locale. Entrambi entrano nel vettore. - Machine-parseable. Un linter valida formato e presenza. Un futuro agente "suggerisci sinonimi mancanti" è banale da costruire sopra.
La convenzione è documentata in un file standalone e applicata da uno script di lint. I nuovi doc senza header falliscono il lint; il team lo tratta come gate per il merge.
Impatto: dopo aver aggiunto gli header ai 22 documenti di ground truth, il recall@3 è passato da 0.45 (post-curazione dello scope) a 0.84 sullo stesso eval set.
Chunking con prepending: l'ereditarietà del contesto
Un dettaglio sottile ma decisivo: quando un documento viene suddiviso in chunk per l'embedding (per sezione H2, ~500 token per chunk), ogni chunk viene embeddato con l'H1 e il Topic header del documento preposti al suo testo:
# Elaborazione Dati da Fonte Esterna
> **Topic**: recupero dati, calcolo valore, integrazione API...
> **Synonyms**: ...
## Formula di calcolo per asset compositi
totalValue = sum(component_i * price_i)
assetPrice = totalValue / totalSupply
Il chunk_text memorizzato è quello originale (nessuna duplicazione al momento del retrieval), ma l'embedding codifica sia il contenuto locale sia l'ancora globale. È questa tecnica che ha chiuso il gap rimanente da 0.84 a 0.96, e insieme a HNSW ha portato all'1.00.
Perché funziona: senza il prepending, il chunk 5 di un documento — in fondo a una sezione tecnica su edge case — ha il suo embedding dominato dal gergo locale di quella sezione. Una query sull'argomento principale del documento ottiene un punteggio basso contro quel chunk. Preporre l'H1+Topic costringe ogni chunk a stare nel vicinato semantico del tema principale del doc. Il modello riceve entrambi i segnali simultaneamente e il vettore lo riflette.
Codifica asimmetrica con Jina v3
Jina v3 supporta la codifica specifica per task. Nel pipeline usiamo:
retrieval.passageper embeddare i documenti (offline, una volta per modifica file)retrieval.queryper embeddare le query utente (online, per ogni prompt)
Non è lo stesso spazio di embedding. Una query e un passage che in modalità simmetrica otterrebbero similarity 0.55 ottengono 0.68 in modalità asimmetrica, perché il modello è stato addestrato a mapparli in regioni compatibili piuttosto che identiche. Ignorare questa distinzione costa circa il 10% di recall.
L'arricchimento della query prima dell'encoding amplifica ulteriormente il retrieval cross-lingua. Un dizionario statico mappa i termini ad alta frequenza dalla lingua locale ai corrispondenti inglesi:
# Dizionario di arricchimento IT→EN
prezzi → prices
recupero → retrieval fetching
liquidità → liquidity
bilancio → balance
transazioni → transactions
calcolo → calculation computation
...
Il dizionario viene applicato come append, non come sostituzione: la query originale viene preservata per il suo segnale in linguaggio naturale, e i termini EN vengono aggiunti come ancora. Questo è importante — sostituire la query con la sua traduzione perde il contesto implicito ("come funziona" vs "what is") che aiuta il modello a disambiguare l'intento.
Costo: poche centinaia di μs di manipolazione stringa. Beneficio: +0.15 sulla top-1 similarity media per query italiane.
L'hook di iniezione
Il livello di retrieval è inutile se nessuno lo chiama. Un comando statico /doc-search richiedeva all'utente o all'agente di ricordarsi di invocarlo. L'adozione era discontinua. La svolta è stata spostare il retrieval nell'hook UserPromptSubmit dell'editor: uno script che gira tra il momento in cui l'utente preme Invio e quello in cui l'agente riceve il messaggio.
L'hook fa quattro cose, in quest'ordine:
- Keyword gate. Una regex di ~90 termini (EN + lingua locale + identificatori di dominio). Se il prompt non menziona nessuna keyword whitelisted, l'hook esce in ~5ms. La maggior parte dei prompt è nella classe "nessuna keyword", quindi il costo ambientale dell'hook è trascurabile.
- Dedup per-sessione. Se lo stesso insieme di keyword ha già attivato un'iniezione prima nella sessione, skip. Evita che l'agente venga bombardato dello stesso blocco documentale per ogni domanda di follow-up.
- Chiamata di retrieval con timeout a 8 secondi. Query all'API e pgvector. Su qualsiasi fallimento — rete, rate limit API, indice down, timeout — l'hook esce pulito e il prompt passa attraverso invariato.
-
Iniezione come contesto aggiuntivo. Assembla un blocco
<doc-search-hint>con i top-2 documenti recuperati ed emette il risultato comehookSpecificOutput.additionalContextnel protocollo hook. L'agente vede questo contesto accanto al prompt originale dell'utente.
- Fail-open ovunque. L'hook è un aiuto, non un gatekeeper. Qualsiasi path di fallimento rilascia silenziosamente il prompt invariato. Un hook rotto non deve mai bloccare l'utente.
- Whitelist keyword hard, non classificatore LLM. Un design precedente usava un piccolo classificatore per decidere se il retrieval fosse "utile per questo prompt". Era più lento, più instabile e più costoso di una regex a 90 termini. La regex cattura il 100% dei prompt ricchi di dominio in pratica. Quando il costo di un falso negativo è "nessun doc iniettato" (l'utente ricade sulla ricerca manuale), un gate deterministico economico batte uno probabilistico intelligente.
- Soglia di similarità post-retrieval. Anche con HNSW e buoni header, alcuni prompt legittimamente non hanno un doc rilevante. Una soglia (attualmente 0.57) scarta i risultati a bassa confidenza. Sotto soglia l'hook non emette nulla — meglio nessuna iniezione che una fuorviante.
- Citazione visibile all'utente. Il blocco iniettato include un'istruzione esplicita all'agente: inizia la risposta con
📚 Docs consultati: <path>. Questo rende visibile il retrieval altrimenti invisibile. L'utente vede quali doc hanno modellato la risposta e può obiettare se l'iniezione era fuori bersaglio. La trasparenza come canale di debug. - Cache per-sessione con chiave = insieme di keyword. Dopo il primo retrieval per un dato insieme di keyword, le match successive entro 2 ore colpiscono una cache JSON locale in ~10ms.
Il post-commit hook: l'indice che si auto-aggiorna
Un indice è utile solo se è fresco. Un git post-commit hook (per-worktree, installato una volta per clone) rileva quando l'ultimo commit tocca docs/*.md e avvia il re-embedding dei file modificati in background. Usa lo stesso script di embedding dell'ingestione iniziale, con un flag --file <path>.
- Non bloccante. Il re-embed gira in background dopo che il commit ritorna. Il workflow dello sviluppatore non viene impattato.
- Fail-silent. Se il servizio di embedding è down, il commit va ugualmente a buon fine. Un cron job (ogni 6 ore) recupera i file che l'hook ha mancato.
- Nessun force-index. Un hash check nello script di embedding salta i file il cui contenuto non è cambiato dall'ultimo embed. Rebase, merge e cherry-pick degli stessi commit non triggerano lavoro ridondante.
Il pattern si generalizza: qualsiasi setup "knowledge base che deve restare in sync con la sorgente" beneficia di un hook SCM come meccanismo di sync primario, con un job schedulato come fallback.
Metodologia di valutazione
Centinaia di ore di engineering sul prompt engineering non valgono nulla senza una metrica. Prima di ogni modifica al pipeline abbiamo eseguito un eval harness su un set curato a mano di 31 prompt realistici (misti IT/EN, brevi e lunghi, alcuni keyword-dense, alcuni conversazionali).
Per ogni prompt il ground truth era una lista di path di doc che un umano esperto si aspetterebbe di vedere tra i primi risultati. Le metriche:
- recall@K — frazione di query in cui almeno un doc di ground truth appare tra i top K. Abbiamo tracciato K=3.
- mean top-1 similarity — media del punteggio cosine più alto per query. Un proxy per la confidenza.
- breakdown per lingua — IT vs EN separatamente, perché i fallimenti erano asimmetrici.
Progressione su sette iterazioni:
| Iterazione | Modifica | Recall@3 | IT | EN |
|---|---|---|---|---|
| Baseline | ivfflat, nessun header | 0.55 | 0.63 | 0.47 |
| + Curazione scope | Escluse cartelle non indicizzate | 0.45 | 0.50 | 0.40 |
| + Consolidamento doc | Unificati doc autorevoli duplicati | 0.61 | 0.75 | 0.47 |
| + Topic headers (11 doc) | Prima tranche di header | 0.65 | 0.81 | 0.50 |
| + H1+Topic prepending | Chunker prepone header a ogni chunk | 0.84 | 0.94 | 0.73 |
| + Header su 22 doc | Completamento ground truth | 0.96 | 1.00 | 0.93 |
| + HNSW | Sostituzione indice ivfflat | 1.00 | 1.00 | 1.00 |
I salti più grandi sono stati il prepending nel chunker (+0.19) e HNSW (+0.04, ma da 0.96 — chiude l'ultimo bug residuale di cluster-miss). Nota che la "curazione dello scope" ha peggiorato il recall: rimuovere una cartella ha reso orfani alcuni riferimenti di ground truth che non erano ancora stati spostati. La lezione è scomoda: eval prima e dopo qualsiasi modifica strutturale, o spedirai una regressione.
Osservazioni in produzione
Dopo due settimane di uso quotidiano:
- Firing rate dell'hook. ~15% dei prompt matcha una keyword. Di questi, ~70% produce un'iniezione sopra soglia. Quindi circa 1 prompt su 10 riceve doc auto-caricati — un targeting stretto che mantiene basso l'inquinamento del contesto.
- Distribuzione di latenza. 50° percentile: 3ms (nessun match). 90° percentile: 15ms (cache hit). 99° percentile: 1.8s (retrieval cold). Il 99p è percettibile ma accettabile quando significa che l'agente ha i doc giusti caricati dal primo turno.
- Costo. Ogni retrieval cold è una chiamata di embedding (~$0.00002) più una query HNSW (gratuita su Postgres esistente). Costo giornaliero totale per un team di 5: meno di $0.30.
- Drift events. Due casi in cui l'indice è diventato stale: (1) un force-push ha riscritto la history e il post-commit hook non è ri-scattato — il cron lo ha recuperato in 6 ore; (2) un doc è stato spostato ma INDEX.md non aggiornato, rompendo il grep di fallback per un ingegnere.
- Errori ricorrenti. L'unico failure mode ricorrente è una query EN breve che menziona un acronimo senza contesto. Il retrieval restituisce doc ragionevoli ma il top-1 resta vicino alla soglia. Un reranker chiuderebbe questo gap ma aggiunge latenza e una nuova dipendenza; è parcheggiato come lavoro futuro.
Cosa si generalizza
Lo stack specifico (Jina, Supabase, pgvector, il protocollo hook di un editor specifico) è intercambiabile. I principi non lo sono.
- Fidati dell'indice solo dopo aver misurato il recall. Gli indici ANN con parametri default aggressivi possono silenziosamente eliminare grandi frazioni del corpus. Misura con un set di ground truth, non con spot check.
- Preferisci HNSW a ivfflat per knowledge base. Il corpus è piccolo (decine di migliaia di chunk, non miliardi) e il recall conta più del build time.
- Inietta contesto, non fare dipendere l'utente dall'esplicitarlo. Un hook al boundary di submit prompt è enormemente più efficace di un comando esplicito che l'utente deve ricordare. L'adozione è il collo di bottiglia, non la capacità.
- Progetta i documenti per il retrieval. Una convenzione rigorosa di header (Topic/Synonyms/See also) pre-carica ogni chunk con le ancore semantiche giuste. È la SEO per la ricerca vettoriale, e vale 5 minuti per documento.
- Chunking con prepending. Ogni chunk deve portare l'identità del documento padre nel suo embedding — non nel payload restituito (dove duplicherebbe il contenuto), ma nel vettore.
- Usa la codifica asimmetrica. Se il modello di embedding supporta
task=retrieval.queryvsretrieval.passage, usala. È il 10% di recall gratuito. - Arricchisci le query, non tradurle. Aggiungere termini nella lingua canonica preserva il segnale di intento originale e ancora il retrieval cross-lingua. Sostituire la query con la traduzione perde il contesto.
- Fail-open ovunque. Un livello di retrieval rotto non deve mai bloccare il lavoro dell'utente. A ogni livello, chiedi: se questo crasha, cosa vede l'utente? La risposta deve essere "niente di peggio di prima".
- Rendi visibile il lavoro invisibile. Quando il sistema auto-inietta contesto, dillo all'utente. Una riga di citazione in cima alla risposta dell'agente basta. Raddoppia come canale di debug quando il retrieval va storto.
- Eval prima di ogni modifica. Senza un set di ground truth, ogni tweak è un'ipotesi. Con uno, le regressioni emergono in secondi dal deploy.
Conclusione
Il lavoro interessante sul RAG non è costruire il servizio di retrieval. Ogni vendor cloud lo vende ormai. Il lavoro interessante è costruire il pipeline intorno — la cadenza di ingestione, il chunker, le convenzioni degli header, l'arricchimento delle query, il trigger di iniezione, il plumbing fail-open, l'eval harness che cattura le regressioni prima degli utenti.
Fai bene quelle cose e il livello di retrieval diventa noioso: Jina o Voyage o OpenAI, pgvector o Pinecone o Weaviate. Non cambia molto. Falle male e nessun vendor ti salverà.
Il pipeline è il prodotto.
Approfondimenti collegati
- Fondamenti del RAG: RAG — Retrieval Augmented Generation
- Connettere il retrieval a tool esterni: MCP (Model Context Protocol)
- Integrare RAG in un sistema agentico: Orchestrazione di Agenti
- Ottimizzare i prompt per il RAG: Prompt Engineering