Knowledge management AI-assisted per codebase legacy: memoria persistente su progetti di 10+ anni
Il 15 marzo 2026 ho completato la prima versione funzionante del mio sistema di knowledge management AI-assisted sulla codebase di riferimento che uso come campo di prova: 200.000 righe di Symfony 7.2 su PHP 8.3, 12 anni di storia git (ricostruita artificialmente per simulare l'età di un legacy vero), 4.300 commit, 780 issue tracker chiuse, 50 pagine di wiki interne, 12 ADR (architectural decision record) scritti nel corso del tempo. L'infrastruttura è un Hetzner EX101 (Intel Core i9-13900, 64 GB RAM DDR5, 2x NVMe 1,92 TB in RAID 1, Debian 12), PostgreSQL 16 con pgvector 0.7, Python 3.12 con Langchain 0.3, Nomic Embed Text v1.5 come modello di embedding (pinnato per digest come ho descritto nell'articolo sulla supply chain security di applicazioni AI), Claude Sonnet 4.6 come generatore di risposte via Anthropic API. L'obiettivo concreto che mi sono posto era semplice: voglio poter chiedere "perché la tabella orders ha il campo legacy_cart_ref che accetta NULL?" e avere una risposta che non è la descrizione statica dello schema - è la storia di quella colonna: quando è stata aggiunta, in risposta a quale issue, da quale commit, con quale motivazione nella descrizione. Dopo sei settimane di rodaggio, il sistema risponde correttamente a 87 su 100 domande di quel tipo sulla mia codebase di test. È il 13% di errori, e sono proprio quegli errori che raccontano la parte più interessante di come è fatto - e di come NON è fatto - questo sistema.
Perché un RAG standard sulla documentazione non risponde alle domande su decisioni legacy?
La risposta in una frase è che la documentazione di un progetto legacy non contiene la storia: contiene lo stato attuale. Un RAG su wiki, README e docblock PHP ti dice cosa fa legacy_cart_ref oggi, non perché esiste. La storia vive nel corollario della codebase: git log, issue tracker, commit message, wiki history (non solo la versione corrente), messaggi di Slack archiviati, email interne. Indicizzare solo il codice e i docs è come leggere solo l'ultimo capitolo di un romanzo - sai come finisce, ma la trama ti sfugge.
La seconda ragione è strutturale al retrieval. In un RAG classico la ricerca è piatta: il sistema trova i chunk più simili al query vector, li passa al modello, il modello risponde. Su una codebase legacy il chunk più simile alla domanda sulla tabella orders è quasi certamente il file attuale che la definisce - che è anche il chunk meno informativo sulla decisione originale di aggiungere il campo. Il RAG piatto ti dà la risposta più ovvia e meno utile. La soluzione strutturale è ibrida: indicizzi codice, commit, issue, docs, e pesi la rilevanza non solo per similarity semantica ma anche per tipo di sorgente e per recency. Un commit di 8 anni fa che aggiunge legacy_cart_ref con un messaggio che spiega perché è più prezioso, per quella specifica domanda, di un migliaio di file che la usano oggi.
Se vuoi vedere come costruisco sistemi di knowledge management AI che trasformano la storia invisibile di una codebase in asset interrogabile, nel mio hub sull'automazione AI per aziende trovi articoli sui pattern che uso - documentazione automatica, classificazione documentale, retrieval con provenance - con criterio comune di far emergere il ragionamento storico che tipicamente resta chiuso in teste individuali di developer.
La differenza fra indicizzare codice e indicizzare storia
Un modello mentale che mi aiuta a progettare questi sistemi è distinguere fra sei layer temporali in una codebase. Il presente: lo stato attuale dei file. Il recente: commit e issue degli ultimi 90 giorni - ancora in memoria dei developer attivi. L'intermedio: l'anno passato - nessuno se lo ricorda nei dettagli ma alcuni sanno dove guardare. Lo storico: 2-5 anni indietro - sopravvive solo in git. L'archeologico: 5-10 anni - solo commit senza contesto di conversazione. Il perduto: oltre 10 anni - persino git perde nitidezza, i primi commit hanno messaggi tipo "initial commit" senza contesto.
Il presente è l'unico layer indicizzato bene dai RAG classici. Il recente è parzialmente accessibile se hai buone pratiche di commit. L'intermedio richiede ingestione sistematica di git log. Lo storico richiede anche l'issue tracker e la wiki history. L'archeologico richiede di recuperare e preservare ADR e conversazioni di progettazione. Il perduto è, salvo casi rari, effettivamente perduto - e parte della scrittura di un sistema di KM è marcare esplicitamente cosa è perduto invece che far finta di poter rispondere. Un modello che inventa una storia plausibile per un commit senza contesto è peggio di uno che risponde "il contesto originale di questa decisione non è recuperabile, il codice è dal 2012".
L'ingestione multi-sorgente: commit, issue, wiki history, ADR
La pipeline di ingestione tratta sei sorgenti con parser dedicati. Codice sorgente: chunk a grana di funzione/classe via parser AST come ho descritto nell'articolo sulla wiki tecnica con parser AST e freshness loop. Commit git: ogni commit è un chunk con metadata {hash, author, date, files_changed, message}. Issue tracker: ogni issue chiusa è un chunk con {id, title, body, comments_concat, resolution, date_closed, related_commits}. Wiki history: ogni versione storica di una pagina wiki è un chunk separato con {page, version, date, diff_from_previous}. ADR: ogni documento ADR è un chunk con {id, status, date_decided, context, decision, consequences}. Conversazioni di progettazione: email archiviate o trascritti di meeting (se preservati) come chunk con {date, participants, topic, resolution}.
Ogni chunk va in pgvector con tre colonne addizionali oltre all'embedding: source_type (codice, commit, issue, wiki, adr, conversation), authored_at (timestamp della creazione originale), indexed_at (quando il chunk è entrato nell'indice). I tre campi sono la chiave di tutto il retrieval pesato temporale di cui parlo nella prossima sezione.
@dataclass
class KmChunk:
content: str
embedding: list[float] # 768d Nomic v1.5
source_type: str # code|commit|issue|wiki|adr|conversation
source_ref: str # file path / commit hash / issue URL / wiki page
authored_at: datetime # data originale del contenuto
indexed_at: datetime # data di ingestione nell'indice
metadata: dict # campi aggiuntivi specifici della sourceUn processo notturno di ingestione fa pull incrementale delle sei sorgenti, calcola gli embedding, fa batch INSERT dentro pgvector. Nel mio laboratorio il totale dei chunk è di 48.200 - 21.000 codice, 4.300 commit, 780 issue, 320 pagine wiki (somma delle versioni storiche), 12 ADR, 0 conversazioni (le email sono sintetiche e non preservate nella sandbox). Il footprint dell'indice HNSW su pgvector è 280 MB, che tiene comodo in RAM.
Il retrieval pesato per recency: quando "più nuovo" non è "più rilevante"
Il punto tecnicamente più interessante del sistema è che il pesamento della rilevanza non è uniforme per source_type. Per codice, la ricerca premia il presente - file recenti e attivi. Per commit, premia il passato rispetto alla data del query - se stai chiedendo di un campo aggiunto 5 anni fa, vuoi trovare il commit di 5 anni fa, non quelli dell'ultimo mese. Per issue, premia la data di chiusura più vicina al periodo di interesse. Per ADR, premia le status vigenti (accepted, superseded if newer exists). Per wiki, premia la prima versione che ha introdotto un concetto, non le revisioni successive che lo modificano.
Il pattern implementativo è weighted re-ranking: la query vector ritrova i top 100 chunk per similarity, poi un ri-ordinamento applica un punteggio combinato per source_type e distanza temporale, poi si selezionano i top 10 per il prompt al modello. La formula che uso è semplice:
def rerank_score(chunk: KmChunk, query_vector: list[float], query_time_anchor: datetime | None) -> float:
similarity = cosine_similarity(chunk.embedding, query_vector)
# Temporal weight: per "commit", premia se prossimo all'anchor
if chunk.source_type == "commit" and query_time_anchor:
age_days = abs((chunk.authored_at - query_time_anchor).days)
temporal = max(0, 1 - age_days / 1825) # decay su 5 anni
elif chunk.source_type == "adr" and chunk.metadata.get("status") == "accepted":
temporal = 1.0
elif chunk.source_type == "code":
age_days = (datetime.now() - chunk.authored_at).days
temporal = max(0, 1 - age_days / 365) # decay annuale
else:
temporal = 0.5
# Source weight: privilegia ADR e issue per domande "perché"
source_weight = {"adr": 1.3, "issue": 1.2, "commit": 1.1, "wiki": 1.0, "code": 0.9, "conversation": 1.1}
return similarity * 0.6 + temporal * 0.2 + source_weight.get(chunk.source_type, 1.0) * 0.2Il query_time_anchor è il punto più sottile. Quando l'utente chiede "perché fu aggiunto il campo X nel 2019?", l'anchor temporale è il 2019 e il re-ranker premia commit e issue di quel periodo. Quando l'utente chiede "perché esiste il campo X" senza vincolo temporale, l'anchor è None e il ranking è puramente semantic+source. L'estrazione dell'anchor è fatta da un intent parser - un'altra chiamata LLM che precede il retrieval vero.
La memoria cross-session: ricordarsi di cosa l'utente ha già chiesto
Un sistema di KM che risponde bene a domande isolate ma dimentica tutto fra una sessione e l'altra è frustrante. Nel mio uso tipico apro una ricerca su "perché orders.legacy_cart_ref?", ricevo una risposta, mi vengono in mente tre domande di follow-up. Se il sistema non correla le mie domande, ogni follow-up parte da zero e io devo ripetere tutto il contesto. La memoria persistente è il pezzo che trasforma il sistema da search a assistente.
Il pattern che uso è una tabella conversation_memory in PostgreSQL con user_id, session_id, query, response, retrieved_chunks_refs, timestamp. Ad ogni nuova query, prima di retrieve-are dal knowledge base, il sistema cerca nella memoria dell'utente le ultime 5 interazioni e le include come parte del contesto al modello. Questo ha due effetti: prima, il modello capisce i follow-up naturali ("e invece nel 2020?") senza dovere che io ripeta "riguardo a orders.legacy_cart_ref"; seconda, il modello evita di ripetere informazioni che ha già detto alla stessa sessione.
def answer(user_id: str, session_id: str, query: str) -> str:
recent_memory = db.fetch_recent_memory(user_id, session_id, limit=5)
intent = parse_intent(query, recent_memory)
candidates = retrieve(intent.query_vector, intent.time_anchor)
top_chunks = rerank(candidates, intent)[:10]
prompt = build_prompt(query, top_chunks, recent_memory)
response = claude_client.messages.create(
model="claude-sonnet-4-6",
system=KM_SYSTEM_PROMPT,
messages=[{"role": "user", "content": prompt}],
max_tokens=1200,
)
db.persist_memory(user_id, session_id, query, response.content[0].text, [c.source_ref for c in top_chunks])
return response.content[0].textLa memoria non è eterna: dopo 7 giorni di inattività la session_id viene chiusa e una nuova query apre una sessione fresca. Questo evita che il contesto diventi rumore per query non correlate alle precedenti. La tabella conversation_memory è anche la base per l'analisi di what do users ask - ovvero quali domande ricorrenti emergono, che è la guida per l'espansione dell'indice e per tuning del re-ranker.
L'output con citation temporale: il pattern "provenance visibile"
Una risposta tipica del sistema non è "il campo orders.legacy_cart_ref è stato aggiunto perché dovevamo mantenere retrocompatibilità con il vecchio sistema di carrello". È strutturata:
Il campo `orders.legacy_cart_ref` è stato introdotto a settembre 2019 per mantenere
retrocompatibilità durante la migrazione dal modulo Cart v1 al Cart v2.
Fonti:
- [commit 7f3a9c1 del 2019-09-12] "Aggiunto legacy_cart_ref per supportare ordini
in-flight durante il cutover al Cart v2. Può essere NULL per ordini post-migrazione."
- [issue #1243 chiusa 2019-09-18] "Cart v2 migration: handle in-flight orders"
- [ADR-007 (accepted, 2019-09-05)] "Migrazione incrementale del Cart con preservazione
degli ordini in-flight per 90 giorni post-cutover".
Nota: l'ADR-007 prevedeva la rimozione del campo dopo 90 giorni. Non vedo un commit
successivo che lo rimuova. Potrebbe essere codice morto - richiedere review.Ogni frase del modello è ancorata a una fonte con timestamp. Se il modello allucina qualcosa, l'utente se ne accorge immediatamente perché la fonte citata dice un'altra cosa. Il disclaimer finale ("nota: l'ADR prevedeva la rimozione...") è un pattern che il modello emette quando rileva discrepanze tra fonti - funziona meglio di qualunque unit test per identificare codice legacy non pulito dopo un processo di migrazione completato solo parzialmente.
Il costo operativo e il freshness loop
Un sistema così non ha senso senza un freshness loop. La codebase cambia ogni giorno, nuovi commit arrivano, issue si chiudono, wiki si modificano. Nel mio laboratorio il processo notturno di ingestione (delta su git log, delta su issue tracker, delta su wiki) richiede 14-22 minuti e costa circa 0,40 dollari al giorno di API Anthropic solo per gli embedding dei nuovi chunk (Nomic self-hosted per Chernovsky lo abbasserebbe ulteriormente, ma per questa sandbox uso Voyage AI per varietà e ho misurato questo costo).
Su una codebase piccola-media (300 commit/mese, 50 issue chiuse/mese, 10 wiki edits) il costo mensile dell'ingestione è nell'ordine dei 12-20 dollari. Il costo di retrieval e generation per ogni query utente è 0,02-0,05 dollari (Sonnet 4.6 con prompt da 3-4k token e output da 600-1.200 token). Su 100 query al giorno, sono 60-150 dollari al mese. Totale plausibile per un team di 10 developer che interroga attivamente il sistema: 80-170 dollari al mese, mille volte meno del valore dei giorni di lavoro che il sistema risparmia evitando domande su Slack a chi "si ricorda come era fatto nel 2018".
Il pgvector con HNSW che ho descritto nell'articolo dedicato è esattamente la primitiva di storage che supporta questo sistema: l'indicizzazione di decine di migliaia di chunk in un PostgreSQL dedicato rende il retrieval sub-100 ms indipendentemente dalla dimensione del repo. Senza pgvector servirebbe un vector database dedicato (Qdrant, Weaviate), ma con 48k chunk totali il trade-off è chiaro verso pgvector.
Quando un sistema di KM AI-assisted sulla codebase non si giustifica
Se la tua codebase è sotto le 50.000 righe e sotto i 3 anni di vita, tutto questo è over-engineering - un developer senior tiene in memoria l'intera storia. Se i tuoi commit hanno messaggi tipo "fix", "update", "wip" per il 90% della storia, il sistema non ha storia da estrarre - prima di costruire il KM, stabilisci discipline di commit messaging decenti per i 6 mesi successivi, poi indicizza. Se hai un team di 2 persone che lavorano sullo stesso progetto da sempre, il ritorno del sistema è basso - la conoscenza vive già nelle vostre teste e nei rari accordi informali. Il sistema vive quando la conoscenza deve uscire dalle teste individuali perché le teste ruotano (turnover, crescita, onboarding di juniors).
Il pattern di KM multi-sorgente con pesamento temporale e memoria cross-session si giustifica quando hai contemporaneamente: codebase sopra le 100k righe, storia git di almeno 5 anni, team di 5+ developer con turnover superiore a zero, pratiche di commit e issue tracking che producono messaggi interpretabili, budget per AI nell'ordine dei 100-300 dollari al mese, e necessità concreta di onboarding veloce per nuovi assunti su codebase complesse. In quel punto di ottimo, il sistema non è un'ottimizzazione marginale - diventa la memoria organizzativa che prima viveva solo nelle conversazioni di corridoio e che ora vive in un sistema interrogabile, con tutte le citation rintracciabili.
La differenza fra un team che "ha perso la conoscenza del 2018" e uno che può rispondere in 10 secondi a "perché questo campo esiste" non è di quantità - è di struttura. La storia c'è sempre, vive in git e in strumenti di collaborazione passati. Quello che manca è il layer di indicizzazione che la rende accessibile senza richiedere 40 minuti di git log + issue search + chiedere a chi era lì al tempo. Costruire quel layer non è un progetto pilota pittoresco - è una leva di produttività che paga quotidianamente in ore di developer senior recuperate, in decisioni prese con contesto invece di supposizioni, e in onboarding che si misura in giorni invece di mesi. Il 2026 è l'anno in cui questi sistemi smettono di essere proof of concept e diventano parte standard del tooling dei team di sviluppo seri.
Se gestisci una codebase legacy e pensi di aver perso troppo della sua storia - o temi di perderla quando il prossimo senior saluta - e vuoi capire se il pattern di KM AI-assisted si adatta al tuo contesto, il modulo di preventivo gratuito ti dà una prima lettura in 7 domande, 2 minuti. Ti dico se il tuo progetto rientra nelle cose che so fare bene e, se il caso richiede un profilo diverso, te lo dico e ti indico una direzione utile quando posso.