Agentic Memory
Come costruire un sistema di memoria per agenti memory-aware, capaci di ricordare, recuperare informazioni e apprenderne di nuove durante la loro esecuzione. (PRIMA PARTE)
Un LLM, di per sé, è stateless: ogni volta che lo interroghiamo riparte da zero e ricorda soltanto ciò che riscriviamo nella finestra di contesto.
Un agente, invece, deve portare avanti attività che durano ore o giorni, ricordare cosa ha già provato, imparare dagli errori e riutilizzare le conoscenze acquisite; si presuppone che operi, cioè, in contesto stateful.
La differenza tra le due situazioni sta nell’utilizzo di memoria: un’infrastruttura persistente che permette a un agente di leggere, scrivere e recuperare informazioni durante la propria esecuzione.
Vediamo come costruire lo strato di memoria di un agente, come progettare i memory store per i diversi tipi di memoria e modellare i dati per un recupero efficiente per poi implementare un Memory Manager che orchestri le operazioni di lettura e scrittura in memoria.
Per le implementazioni useremo PostgreSQL con l’estensione pgvector.
I tipi di memoria
Prendendo a prestito concetti in uso nelle scienze cognitive applicati ai sistemi di AI, possiamo individuare quattro tipi principali di memoria:
| Tipo | Persistenza | Cosa conserva | Caratteristica distintiva | Implementazione tipica |
|---|---|---|---|---|
| Short-term / working | breve termine | contesto attivo di una singola inferenza | veloce e immediata, ma volatile | finestra di contesto (prompt, history, output dei tool, documenti recuperati) |
| Episodic | lungo termine | eventi passati specifici, interazioni, esiti | ricorda che cosa è successo e quando | record con timestamp in un vector store; retrieval semantico o ibrido |
| Semantic | lungo termine | fatti strutturati, preferenze, relazioni tra entità | ricorda cosa è vero nel dominio dell’agente | profili di entità, storage relazionale + vettoriale |
| Procedural | lungo termine | workflow, regole decisionali, pattern di comportamento | ricorda come si fa | system prompt, few-shot, regole gestite dall’agente |
la finestra di contesto context window del modello contiene tutto ciò su cui il modello può ragionare in una singola chiamata di inferenza. Include system prompt, cronologia della conversazione, output dei tool e documenti recuperati. Va pensata come la RAM: è veloce e immediata, ma si azzera quando la sessione termina.
Episodic memory registra eventi passati specifici: interazioni, azioni, esiti. (es.: un agente ricorda che il deploy di un utente è fallito martedì scorso per una variabile d’ambiente mancante.) È particolarmente efficace per il case-based reasoning: usare eventi, azioni e risultati passati per migliorare le decisioni future. Di solito si rappresenta come record con timestamp in un database vettoriale e si recupera via ricerca semantica o ibrida al momento della query.
Semantic memory è ciò che l’agente conosce, indipendentemente da quando l’ha imparato. Contiene informazioni come le preferenze dell’utente, le nozioni del dominio in cui l’agente lavora e le relazioni tra le entità che incontra (persone, aziende, prodotti, sistemi).
Differenza tra le due memorie long term appena viste: l’episodica ricorda un evento datato (“il 12 marzo l’utente ha chiesto di fare un riassunto delle risposte”), la semantica ricorda il fatto che ne deriva (“l’utente preferisce risposte concise”). L’evento ha una data, il fatto no.
Procedural memory codifica come si fanno le cose: workflow, regole decisionali e pattern appresi. In pratica compare come istruzioni nel system prompt, esempi few-shot o set di regole gestiti dall’agente e affinati con l’esperienza.
La differenza tra i quattro tipi si riassume in una domanda diversa per ciascuno:
- short-term → cosa c’è nel contesto corrente?
- episodica → cosa è successo in passato?
- semantica → cosa ho appreso (fatti, preferenze, relazioni)?
- procedurale → come procedo in situazioni come questa?
Il passaggio da breve a lungo termine non è automatico: qualcosa deve decidere cosa promuovere dalla working memory alla memoria persistente, e cosa scartare. È esattamente il compito del Memory Manager, ed è il motivo per cui più avanti distingueremo le operazioni deterministiche da quelle agent-triggered.
Questi tipi non operano in isolamento. Gli agenti di produzione hanno tipicamente bisogno di tutti e quattro gli strati che lavorano insieme: la working memory ospita il ragionamento corrente, mentre episodi, fatti e procedure persistenti alimentano il contesto al momento giusto.
L’agent stack
L’insieme di strumenti e tecnologie che permettono a un agente AI di funzionare in modo affidabile ed efficiente in produzione è chiamato agent stack.
Lo stack completo è composto da parecchi livelli, ma per i nostri scopi possiamo comprimerlo in tre soli strati: Application Layer, Data Layer e Infrastructure Layer.
Poiché però stiamo ragionando in ottica agentica, il Data Layer viene detto Memory Layer.
Un Memory Layer conserva dati organizzati in modo che l’agente possa recuperarli e usarli per decidere.
In sostanza rappresenta l’esperienza accumulata dall’agente nel tempo.
flowchart TB
A["Application Layer<br/>(agente, tool, prompt, orchestrazione)"]
M["Memory Layer<br/>(Memory Core + Memory Manager)"]
I["Infrastructure Layer<br/>(database, modelli, compute)"]
A --> M
M --> I
Il Memory Layer: Memory Core e Memory Manager
Il Memory Layer è composto da due elementi che lavorano in coppia:
- il Memory Core, cioè l’insieme dei tipi di memoria dell’agente (conversazionale, knowledge base, workflow, summary, toolbox, entity) e dei relativi store;
- il Memory Manager, cioè la logica che governa come si legge e si scrive su quegli store.
Insieme danno vita ad agenti memory-augmented, capaci di gestire attività continue, di operare su task long-horizon (compiti che si estendono su orizzonti temporali lunghi) e di adattarsi a nuove informazioni.
Il Memory Core risponde alla domanda “che cosa ricorda l’agente?”, il Memory Manager alla domanda “come si accede alle informazioni memorizzate?”.
Il Memory Manager
Il Memory Manager si può descrivere come un’astrazione costruita sopra il database: al suo interno vivono i flussi e la logica di controllo che regolano lettura e scrittura verso i memory store.
In pratica è una classe che espone metodi CRUD sulle tabelle che rappresentano la memoria dell’agente.
L’agente non scrive mai SQL: chiama metodi come
write_knowledge_base()oread_conversational_memory()e il Memory Manager si occupa di tradurli in operazioni sul database.
I memory store e i loro requisiti di storage
Ogni tipo di memoria ha la propria tabella dedicata all’interno del database.
Mentre la memoria conversazionale può essere contenuta all’interno di una normale tabella, le altre richiedono estensione vettoriale per poter contenere gli embedding.
| Tipo di memoria | Analogia umana | A cosa serve | Storage | Strategia di retrieval |
|---|---|---|---|---|
| Conversational | memoria episodica (la traccia persistita della working memory) | cronologia della chat per thread | tabella SQL | match esatto su thread_id |
| Knowledge Base | memoria semantica a lungo termine | fatti, documenti, risultati di ricerca | vector store (colonna vector) | similarità semantica |
| Workflow | memoria procedurale | pattern di azioni appresi | vector store (colonna vector) | similarità semantica + filtro sui metadati |
| Toolbox | memoria delle competenze | tool e capacità disponibili | vector store (colonna vector) | similarità semantica |
| Entity | memoria semantica | persone, luoghi, sistemi citati | vector store (colonna vector) | similarità semantica |
| Summary | memoria compressa | contesto condensato per conversazioni lunghe | vector store (colonna vector) | similarità semantica |
| Tool Log | traccia di audit | input/output grezzi dei tool e stato di esecuzione | tabella SQL | match esatto su thread_id + ordinamento temporale |
Per ognuno di questi store il Memory Manager espone almeno le operazioni di lettura e scrittura; nulla vieta di implementare anche update, delete e create a seconda delle necessità.
Operazioni deterministiche e operazioni agent-triggered
Le operazioni di memoria non sono tutte uguali: si distinguono in base a chi decide di invocarle.
Operazioni deterministiche. Vengono eseguite in modo programmatico, secondo una pianificazione fissa.
Esempio: salvataggio di ogni messaggio della conversazione nella tabella conversazionale.
Operazioni agent-triggered. Le operazioni in memoria vengono fornite all’agente sotto forma di tool, e sarà l’agente stesso a decidere quando e dove usarle, sulla base dell’intento e della situazione. Il momento in cui la memoria viene scritta o interrogata è lasciato alla discrezione dell’agente.
Decidere quali operazioni collocare nell’una o nell’altra categoria è una delle scelte di progettazione più importanti della memory engineering.
Operazioni deterministiche
Le operazioni deterministiche vengono eseguite a ogni turno oppure sotto condizioni fisse ed esplicite (ad esempio “sempre all’inizio del loop dell’agente”, “sempre dopo l’esecuzione di un tool”).
Il recupero della memoria viene comunemente eseguito all’inizio di ogni ciclo dell’agente per tre motivi.
Il primo è che senza il contesto pregresso l’agente si comporta come se fosse stateless e ricomincia da capo ogni volta.
Il secondo è più sottile: l’agente non può decidere di cercare ciò di cui ignora l’esistenza. Se fosse lui a dover decidere se consultare la memoria, dovrebbe indovinare che cosa c’è dentro, e si creerebbe un problema circolare — serve la memoria per sapere di quale memoria si ha bisogno.
Il terzo è la prevedibilità: caricare sempre la memoria produce un comportamento coerente e rende il sistema più facile da valutare e da debuggare.
Lo stesso vale per la scrittura. Persistere conversazioni, workflow ed entità è spesso deterministico per affidabilità (non vogliamo che l’agente “si dimentichi di salvare”), per completezza (ogni interazione va registrata, perché i salvataggi selettivi creano buchi di contesto che più avanti rompono i task long-horizon) e per ridurre il carico cognitivo del modello, che deve concentrarsi sull’esecuzione del compito e non sulla contabilità della memoria.
Operazioni agent-triggered
Le operazioni lasciate all’agente sono quelle che richiedono giudizio: “questa informazione merita di diventare una preferenza di cui tenere conto?”, “è il momento di consolidare o riassumere?”, “mi serve un recupero più profondo rispetto al precaricamento di base?”, “questa memoria va rafforzata, aggiornata, fusa o lasciata decadere?”.
I vantaggi principali sono:
La rilevanza: non tutto merita di essere conservato a lungo termine, e l’agente sa distinguere il segnale (preferenze, decisioni, vincoli) dal rumore.
Il controllo di costi e latenza: retrieval profondo, reranking, summarization e consolidamento costano token e tempo, quindi attivarli solo quando servono riduce l’overhead.
La qualità della gestione della memoria: decidere cosa archiviare e come comprimerlo richiede una comprensione semantica dell’intento, ed è proprio ciò in cui il modello è bravo.
Anche le chiamate a tool esterni (ricerca web, lookup su database esterni, job di summarization costosi) sono tipicamente agent-triggered: solo l’agente può giudicare se servano informazioni aggiuntive. I tool introducono latenza e costi di API, e scegliere cosa cercare richiede di aver capito l’obiettivo dell’utente.
La Memory Unit
Una Memory Unit è la più piccola unità atomica di rappresentazione dell’informazione conservata in un database e utilizzata all’interno di un sistema agentico.
In termini pratici corrisponde quasi sempre a una riga di una tabella.
Una Conversational Memory Unit contiene il timestamp, il ruolo dell’entità che sta conversando e il contenuto della conversazione stessa:
timestamp | role | content |
|---|---|---|
2026-01-08 10:14:02 | user | “Trova i paper recenti sull’esplorazione spaziale” |
2026-01-08 10:14:09 | assistant | “Ho trovato tre paper pertinenti…” |
Una Workflow Memory Unit è più ricca: contiene il contenuto del workflow, il suo tipo, il timestamp e una rappresentazione vettoriale embeddata di parte del contenuto.
Il contenuto di una workflow memory unit è tipicamente costituito dai passi eseguiti e dal loro esito, cioè la traccia di come l’agente ha portato a termine (o mancato) un obiettivo.
content | workflow_type | timestamp | embedding |
|---|---|---|---|
| passi eseguiti + esito | tipo di workflow | data e ora | vettore del contenuto |
Context Engineering
Il context engineering è la pratica di selezionare con giudizio il contenuto che passiamo nella finestra di contesto.
Abbiamo a disposizione molte sorgenti dati, ciascuna capace di fornire una gran quantità di informazioni. La tentazione è di riversarle tutte nel contesto, ma è esattamente l’errore da evitare: dobbiamo invece ragionare con attenzione su quale contesto passare.
L’obiettivo è massimizzare il valore di ogni singolo token presente nella finestra di contesto.
Idealmente vogliamo un rapporto segnale/rumore alto per ogni token: è così che si ottiene l’output e il risultato desiderati.
Riempire il contesto con tutto ciò che abbiamo non rende l’agente più informato, lo rende più confuso.
Ogni token irrilevante è un token che diluisce l’attenzione del modello e costa denaro.
Memory Engineering
La memory engineering è la disciplina che si occupa di costruire e mantenere i sistemi di memoria di un agente AI, in modo che questo possa adattarsi e imparare davvero.
Chi fa memory engineering è responsabile di tutti i processi e le operazioni che avvengono lungo il memory lifecycle.
Il memory lifecycle
Il ciclo di vita della memoria attraversa diverse fasi.
Si parte da una sorgente di dati grezzi, che passa attraverso una pipeline di ingestion e viene poi arricchita, ad esempio con un modello di embedding oppure con un LLM che ne aumenta il contenuto informativo.
Il risultato viene archiviato nel database.
Si continua con l’organizzazione dell’informazione, che comprende l’indicizzazione e la mappatura delle relazioni tra le informazioni.
Si arriva quindi al recupero, che può avvenire con diverse strategie di retrieval (testuale, vettoriale, etc).
L’informazione recuperata viene passata all’LLM. Ovviamente anche l’output dell’LLM può diventare memoria.
flowchart LR
R["Raw data source"] --> IN["Ingestion"]
IN --> EN["Enrichment<br/>(embedding / LLM)"]
EN --> ST["Storage<br/>(short & long term)"]
ST --> OR["Organization<br/>(indexing, relazioni)"]
OR --> RE["Retrieval<br/>(lexical, vector, graph, hybrid)"]
RE --> LLM["LLM"]
LLM --> SE["Serialization<br/>& augmentation"]
SE --> ST
È proprio questo ciclo a rendere possibile l’apprendimento continuo di cui un agente ha bisogno per affrontare i task long-horizon.
Le discipline che compongono la memory engineering
“Memory engineering” è una combinazione di discipline già esistenti, di cui riprende pratiche e principi per implementare in modo efficiente le operazioni di memoria negli agenti AI.
| Disciplina | Cosa apporta alla memory engineering |
|---|---|
| Database engineering | transazioni ACID, storage persistente, comprensione delle architetture di storage |
| Agent engineering | come progettare l’agente e dove collocare le operazioni di memoria |
| Machine learning engineering | fine-tuning dei modelli di embedding o di small language model, versionamento dei modelli, pipeline di reranking, continual learning |
| Information retrieval | implementazione e ottimizzazione delle strategie di retrieval, indici vettoriali e altre strategie di indicizzazione |
Non c’è nulla di realmente nuovo nella memory engineering: è l’intersezione di discipline che conosciamo già, applicata a un problema nuovo.
Da agenti memory-augmented ad agenti memory-aware
Si parte da un’implementazione naive di agente memory-augmented, dotato della sola memoria conversazionale: in pratica ha soltanto lo storico delle interazioni.
Introducendo un’allocazione esplicita dei tipi di memoria si arriva a un agente pienamente memory-augmented, capace di recuperare informazioni da store diversi — conversazionale, workflow, toolbox e le altre forme di memoria presenti nel database.
Il passaggio successivo è il più importante da comprendere: noi possiamo rendere l’agente memory-aware, cioè consapevole della propria memoria. Servono quattro passi:
- Dare all’agente consapevolezza dei memory store tramite il system prompt, così che sappia quali memorie possiede e a cosa servono.
- Fornire le operazioni di memoria come tool, in modo che l’agente possa archiviare, recuperare, leggere e dimenticare a propria discrezione.
- Dare all’agente la capacità di ragionare lungo il memory lifecycle, non solo di eseguirne i passaggi.
- Segmentare la finestra di contesto in porzioni allocate a tipi di memoria specifici.
flowchart LR
N["Agente naive<br/>(solo memoria conversazionale)"] --> A["Memory-augmented<br/>(tipi di memoria espliciti)"]
A --> W["Memory-aware<br/>(prompt + tool + reasoning + contesto segmentato)"]
La differenza è sostanziale: un agente memory-augmented ha una memoria, un agente memory-aware sa di averla e sa come usarla.
L’implementazione
Vediamo ora come questi concetti prendono forma nel codice.
Useremo PostgreSQL con l’estensione pgvector come storage, un modello di embedding di Hugging Face e l’integrazione ufficiale langchain-postgres (PGEngine + PGVectorStore) per i vector store.
Partiamo da un caso d’uso concreto: ArxivScout, un assistente che cerca tra i paper di arXiv e aiuta a investigare un argomento su più sessioni. Deve ricordare scoperte, fonti e preferenze dell’utente, così da non rifare ogni volta lo stesso lavoro di ricerca.
Setup dell’ambiente
Per lo sviluppo locale possiamo avviare PostgreSQL già con pgvector abilitato tramite l’immagine Docker ufficiale:
1
2
3
4
docker run --name pgvector-container \
-e POSTGRES_USER=langchain -e POSTGRES_PASSWORD=langchain \
-e POSTGRES_DB=agentic_memory -p 6024:5432 \
-d pgvector/pgvector:pg16
Le dipendenze Python necessarie sono:
1
2
3
4
5
langchain-postgres
langchain-huggingface
sentence-transformers
psycopg[binary]
datasets
1. Connessione al database
A differenza di un’integrazione in cui una sola connessione serve sia le tabelle SQL sia i vector store, con langchain-postgres servono due oggetti distinti:
- una connessione raw (
psycopg) per le tabelle SQL (conversazionale e tool log); - un
PGEngine, cioè un pool di connessioni SQLAlchemy, usato daPGVectorStore.
Il setup è il seguente:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import psycopg
from langchain_postgres import PGEngine
CONNECTION_STRING = "postgresql+psycopg://langchain:langchain@localhost:6024/agentic_memory"
# Connessione raw per le tabelle SQL (conversazionale e tool log)
database_connection = psycopg.connect(
"postgresql://langchain:langchain@localhost:6024/agentic_memory"
)
with database_connection.cursor() as cur:
cur.execute("CREATE EXTENSION IF NOT EXISTS vector")
database_connection.commit()
# Pool di connessioni per i vector store
pg_engine = PGEngine.from_connection_string(url=CONNECTION_STRING)
print("Using user:", database_connection.info.user)
2. Il modello di embedding
Altro componente chiave è il modello di embedding, che useremo per trasformare il testo in vettori.
Esistono miriadi di modelli di embedding, che possono essere usati con langchain-huggingface.
In questo esempio usiamo paraphrase-mpnet-base-v2 prelevato da Hugging Face attraverso l’integrazione LangChain, usando la libreria sentence-transformers.
1
2
3
4
5
from langchain_huggingface import HuggingFaceEmbeddings
embedding_model = HuggingFaceEmbeddings(
model_name="sentence-transformers/paraphrase-mpnet-base-v2"
)
3. Le tabelle dei memory store
Definiamo i nomi delle tabelle che rappresentano le diverse forme di memoria dell’agente.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# Nomi delle tabelle per ciascun tipo di memoria
CONVERSATIONAL_TABLE = "conversational_memory" # Episodic memory
KNOWLEDGE_BASE_TABLE = "semantic_memory" # Semantic memory
WORKFLOW_TABLE = "workflow_memory" # Procedural memory
TOOLBOX_TABLE = "toolbox_memory" # Procedural memory
ENTITY_TABLE = "entity_memory" # Semantic memory
SUMMARY_TABLE = "summary_memory" # Semantic memory
TOOL_LOG_TABLE = "tool_log_memory" # Tool execution logs
ALL_TABLES = [
CONVERSATIONAL_TABLE,
KNOWLEDGE_BASE_TABLE,
WORKFLOW_TABLE,
TOOLBOX_TABLE,
ENTITY_TABLE,
SUMMARY_TABLE,
TOOL_LOG_TABLE,
]
# Eliminiamo le tabelle esistenti per ripartire da zero
for table in ALL_TABLES:
with database_connection.cursor() as cur:
cur.execute(f"DROP TABLE IF EXISTS {table} CASCADE")
print(f" - {table} (dropped if existed)")
database_connection.commit()
DROP TABLE IF EXISTS ... CASCADE rende superflua la gestione delle eccezioni: se la tabella non c’è, il comando è semplicemente un no-op. Gli indici della tabella vengono eliminati già dal DROP TABLE; CASCADE elimina in più gli oggetti dipendenti, come viste e foreign key di altre tabelle.
Dalle categorie cognitive alle tabelle
I commenti a fianco ai nomi delle tabelle indicano la categoria cognitiva che ogni store implementa. È qui che i quattro tipi di memoria visti all’inizio dell’articolo diventano oggetti concreti nel database.
| Categoria cognitiva | Tabelle | Perché sta lì |
|---|---|---|
| Short-term / working | nessuna tabella | vive nella finestra di contesto, non viene persistita |
| Episodica | conversational_memory | eventi datati: chi ha detto cosa e quando, turno per turno |
| Semantica | semantic_memory, entity_memory, summary_memory | fatti, entità e conoscenza condensata, indipendenti dal momento in cui sono stati appresi |
| Procedurale | workflow_memory, toolbox_memory | come si fanno le cose: sequenze di azioni apprese e strumenti disponibili |
| (nessuna: audit) | tool_log_memory | traccia tecnica delle esecuzioni, serve a debug e osservabilità, non al ragionamento |
La working memory non ha una tabella. È l’unico tipo a breve termine: vive nella finestra di contesto e sparisce a fine sessione. Ciò che persistiamo è la sua traccia, cioè la cronologia della conversazione, che una volta scritta su disco diventa a tutti gli effetti memoria episodica — ed è per questo che conversational_memory è commentata come Episodic memory pur contenendo i messaggi della chat.
Il nome del tipo di memoria e il nome della tabella non coincidono sempre. La knowledge base è memorizzata nella tabella
semantic_memory, non in una ipoteticaknowledge_base_memory.KNOWLEDGE_BASE_TABLE = "semantic_memory".
Una stessa categoria può avere più tabelle. La memoria semantica è divisa in tre store: semantic_memory contiene documenti di dominio ingeriti dall’esterno, entity_memory profili di entità aggiornati incrementalmente, summary_memory sintesi generate dall’agente per comprimere conversazioni lunghe. Stessa natura cognitiva, ma pattern di scrittura e retrieval differenti e quindi tabelle separate.
Il
tool_log_memoryè l’unico store che non corrisponde ad alcuna categoria cognitiva: non è memoria di cui l’agente si serve per ragionare, ma una traccia di audit per noi che sviluppiamo il sistema.
4. La tabella della memoria conversazionale
A differenza dei vector store, la memoria conversazionale usa una tabella tradizionale perché qui ci serve un recupero esatto per thread, non una ricerca per similarità.
Per una conversational memory unit vogliamo catturare il contenuto, il ruolo e il timestamp. Possiamo però aggiungere metadati ulteriori: il campo metadata associato alla memory unit (qui in JSONB, interrogabile e indicizzabile), il campo created_at (che coincide con timestamp finché entrambi restano al default NOW(), ma se ne distingue quando il timestamp dell’evento viene passato dall’applicazione) e un summary_id, pensato per collegare la conversazione ai suoi riassunti.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
def create_conversational_history_table(conn, table_name: str = "conversational_memory"):
"""
Create a table to store conversational history.
Args:
conn: PostgreSQL database connection (psycopg)
table_name: Name of the table to create
"""
with conn.cursor() as cur:
cur.execute(f"DROP TABLE IF EXISTS {table_name} CASCADE")
cur.execute(f"""
CREATE TABLE {table_name} (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
thread_id VARCHAR(100) NOT NULL,
role VARCHAR(50) NOT NULL,
content TEXT NOT NULL,
timestamp TIMESTAMPTZ DEFAULT NOW(),
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
summary_id UUID DEFAULT NULL
)
""")
# Create index on thread_id for faster lookups
cur.execute(f"""
CREATE INDEX idx_{table_name}_thread_id ON {table_name}(thread_id)
""")
# Create index on timestamp for ordering
cur.execute(f"""
CREATE INDEX idx_{table_name}_timestamp ON {table_name}(timestamp)
""")
conn.commit()
print(f"Table {table_name} created successfully with indexes")
return table_name
Gli indici non sono un dettaglio: quello su thread_id rende veloce il recupero di una conversazione, quello su timestamp rende efficiente l’ordinamento cronologico. Insieme garantiscono che la scansione delle righe resti rapida anche quando lo storico cresce.
Con lo stesso approccio creiamo la tool log table, che registra input, output e stato di esecuzione dei tool.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
def create_tool_log_table(conn, table_name: str = "tool_log_memory"):
"""
Create a table to store tool execution logs.
Args:
conn: PostgreSQL database connection (psycopg)
table_name: Name of the table to create
"""
with conn.cursor() as cur:
cur.execute(f"DROP TABLE IF EXISTS {table_name} CASCADE")
cur.execute(f"""
CREATE TABLE {table_name} (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
thread_id VARCHAR(100) NOT NULL,
tool_name VARCHAR(100) NOT NULL,
tool_input JSONB,
tool_output TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'success'
CHECK (status IN ('success', 'error', 'timeout')),
error_message TEXT,
duration_ms INTEGER,
timestamp TIMESTAMPTZ DEFAULT NOW(),
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
)
""")
# Retrieve the execution trace of a single conversation, in chronological order
cur.execute(f"""
CREATE INDEX idx_{table_name}_thread_ts
ON {table_name}(thread_id, timestamp DESC)
""")
# Aggregate usage and failures per tool
cur.execute(f"""
CREATE INDEX idx_{table_name}_tool_name ON {table_name}(tool_name)
""")
# Fast lookup of failed executions only
cur.execute(f"""
CREATE INDEX idx_{table_name}_status ON {table_name}(status)
WHERE status <> 'success'
""")
conn.commit()
print(f"Table {table_name} created successfully with indexes")
return table_name
Anche qui le scelte di tipo raccontano l’uso previsto della tabella. tool_input è JSONB e non TEXT perché gli argomenti di una tool call sono già un dizionario strutturato: tenerli interrogabili permette di chiedere al database “tutte le chiamate in cui search_query conteneva X”. tool_output resta invece TEXT, perché i tool restituiscono spesso testo libero.
I campi status, error_message e duration_ms sono ciò che trasforma la tabella da semplice log a strumento di osservabilità: tasso di errore per tool e latenza per tool sono le prime due metriche che servono quando un agente comincia a comportarsi male. Il CHECK sullo stato evita che si accumulino valori scritti in modo incoerente da parti diverse del codice.
Gli indici seguono gli accessi reali: quello composto su (thread_id, timestamp DESC) ricalca la query di read_tool_logs(), che tipicamente recupera le ultime N esecuzioni di un thread; quello su tool_name serve alle aggregazioni per strumento; quello parziale su status resta piccolissimo perché indicizza solo i fallimenti.
Si noti infine che thread_id ha lo stesso tipo usato in conversational_memory: possiamo quindi fare una join tra le due tabelle e ricostruire l’intreccio tra messaggi e chiamate ai tool.
A questo punto invochiamo le due funzioni insieme:
1
2
3
# Creiamo le tabelle SQL della memoria
CONVERSATION_HISTORY_TABLE = create_conversational_history_table(database_connection, CONVERSATIONAL_TABLE)
TOOL_LOG_HISTORY_TABLE = create_tool_log_table(database_connection, TOOL_LOG_TABLE)
5. I vector store
Le tabelle SQL per memoria conversazionale e tool log sono pronte. Ora servono le tabelle capaci di gestire dati vettoriali.
Creiamo vector store distinti, uno per ogni tipo di memoria. Ciascuno è appoggiato alla propria tabella PostgreSQL (con colonna vector) e usa lo stesso modello di embedding, per garantire la coerenza tra gli spazi vettoriali.
Usiamo l’integrazione ufficiale langchain-postgres: PGEngine gestisce il pool, init_vectorstore_table() crea lo schema con la colonna vector(N), e PGVectorStore.create_sync() istanzia lo store. Importiamo anche la DistanceStrategy (qui COSINE_DISTANCE) e la HybridSearchConfig per l’hybrid search.
Per tenere ordinata la creazione dei vector store definiamo una classe che ne astrae metodi e istanze: la chiamiamo StoreManager.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
from langchain_postgres import PGVectorStore, Column
from langchain_postgres.v2.indexes import DistanceStrategy
from langchain_postgres.v2.hybrid_search_config import (
HybridSearchConfig,
weighted_sum_ranking,
)
class StoreManager:
"""Manages all stores (vector stores and SQL tables) with getter methods for easy access."""
def __init__(
self,
engine,
embedding_function,
table_names,
distance_strategy,
conversational_table,
vector_size: int = 768, # -> dimensione di paraphrase-mpnet-base-v2
tool_log_table: str | None = None,
):
"""
Initialize all stores.
Args:
engine: PGEngine connection pool
embedding_function: Embedding model to use
table_names: Dict with keys: knowledge_base, workflow, toolbox, entity, summary
distance_strategy: Distance strategy for vector search
conversational_table: Name of the conversational history SQL table
vector_size: Embedding dimension (must match the model)
tool_log_table: Name of the SQL tool log table
"""
self.engine = engine
self.embedding_function = embedding_function
self.distance_strategy = distance_strategy
self.vector_size = vector_size
self._conversational_table = conversational_table
self._tool_log_table = tool_log_table
# Hybrid search: la colonna tsvector deve nascere insieme alla tabella
# (l'indice GIN verrà applicato dopo, con apply_hybrid_search_index())
hybrid_config = HybridSearchConfig(
tsv_column="content_tsv",
tsv_lang="pg_catalog.english",
fusion_function=weighted_sum_ranking,
primary_top_k=5,
secondary_top_k=5,
index_name="kb_tsv_index",
index_type="GIN",
)
# Metadati tipizzati e filtrabili (solo knowledge base)
kb_metadata_columns = [
Column("arxiv_id", "TEXT"),
Column("subjects", "TEXT"),
Column("submission_date", "TEXT"),
]
self._knowledge_base_vs = self._init_store(
table_names["knowledge_base"],
metadata_columns=kb_metadata_columns,
hybrid_search_config=hybrid_config,
)
self._workflow_vs = self._init_store(table_names["workflow"])
self._toolbox_vs = self._init_store(table_names["toolbox"])
self._entity_vs = self._init_store(table_names["entity"])
self._summary_vs = self._init_store(table_names["summary"])
def _init_store(self, table_name, metadata_columns=None, hybrid_search_config=None):
# 1. crea la tabella con la colonna vector(768)
self.engine.init_vectorstore_table(
table_name=table_name,
vector_size=self.vector_size,
metadata_columns=metadata_columns or [],
overwrite_existing=True,
hybrid_search_config=hybrid_search_config,
)
# 2. istanzia lo store sulla tabella appena creata
return PGVectorStore.create_sync(
engine=self.engine,
embedding_service=self.embedding_function,
table_name=table_name,
metadata_columns=[c.name for c in (metadata_columns or [])],
distance_strategy=self.distance_strategy,
hybrid_search_config=hybrid_search_config,
)
def get_knowledge_base_store(self):
"""Return the knowledge base vector store."""
return self._knowledge_base_vs
def get_workflow_store(self):
"""Return the workflow vector store."""
return self._workflow_vs
def get_toolbox_store(self):
"""Return the toolbox vector store."""
return self._toolbox_vs
def get_entity_store(self):
"""Return the entity vector store."""
return self._entity_vs
def get_summary_store(self):
"""Return the summary vector store."""
return self._summary_vs
def get_conversational_table(self):
"""Return the conversational history table name."""
return self._conversational_table
def get_tool_log_table(self):
"""Return the tool log table name."""
return self._tool_log_table
Ogni store nasce in due passi: prima init_vectorstore_table() crea la tabella tipizzata (vector(768)), poi PGVectorStore.create_sync() collega lo store a quella tabella. I parametri chiave sono: l’engine (PGEngine); l’embedding service, cioè il modello inizializzato prima; il nome della tabella; la distance strategy, qui DistanceStrategy.COSINE_DISTANCE (operatore pgvector <=>).
Ripetiamo il procedimento per ogni forma di memoria: knowledge base (memoria semantica), workflow, toolbox, entity e summary.
Sulla sola knowledge base dichiariamo colonne di metadati tipizzate e filtrabili (arxiv_id, subjects, submission_date) e passiamo la HybridSearchConfig già in fase di creazione.
Per l’hybrid search la colonna
tsvectordeve nascere insieme alla tabella: per questo laHybridSearchConfigviene passata sia ainit_vectorstore_table()sia acreate_sync(). L’indice GIN che accelera la full-text search, invece, si applica in un secondo momento conapply_hybrid_search_index(): lo faremo nella sezione 6, insieme agli indici vettoriali.
Lo StoreManager non gestisce solo i vector store: conserva anche i nomi delle tabelle SQL (conversazionale e tool log), così da diventare l’unico punto di accesso a tutti gli store del sistema.
Creiamo l’istanza:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
VECTOR_SIZE = 768 # dimensione di paraphrase-mpnet-base-v2
# Create StoreManager instance
store_manager = StoreManager(
engine=pg_engine,
embedding_function=embedding_model,
table_names={
"knowledge_base": KNOWLEDGE_BASE_TABLE,
"workflow": WORKFLOW_TABLE,
"toolbox": TOOLBOX_TABLE,
"entity": ENTITY_TABLE,
"summary": SUMMARY_TABLE,
},
distance_strategy=DistanceStrategy.COSINE_DISTANCE,
conversational_table=CONVERSATION_HISTORY_TABLE,
vector_size=VECTOR_SIZE,
tool_log_table=TOOL_LOG_HISTORY_TABLE,
)
E recuperiamo tutti gli store attraverso i getter del manager:
1
2
3
4
5
6
7
8
# Get all stores via the manager
conversation_table = store_manager.get_conversational_table()
knowledge_base_vs = store_manager.get_knowledge_base_store()
workflow_vs = store_manager.get_workflow_store()
toolbox_vs = store_manager.get_toolbox_store()
entity_vs = store_manager.get_entity_store()
summary_vs = store_manager.get_summary_store()
tool_log_table = store_manager.get_tool_log_table()
6. Gli indici
Per garantire un recupero efficiente delle informazioni bisogna sempre creare un indice.
Con PGVectorStore gli indici si creano direttamente sullo store, senza helper esterni:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from langchain_postgres.v2.indexes import HNSWIndex
print("Creating vector indexes...")
knowledge_base_vs.apply_vector_index(
HNSWIndex(name="knowledge_base_hnsw", m=16, ef_construction=64)
)
workflow_vs.apply_vector_index(HNSWIndex(name="workflow_hnsw"))
toolbox_vs.apply_vector_index(HNSWIndex(name="toolbox_hnsw"))
entity_vs.apply_vector_index(HNSWIndex(name="entity_hnsw"))
summary_vs.apply_vector_index(HNSWIndex(name="summary_hnsw"))
# Indice GIN sulla colonna tsvector della knowledge base,
# per la parte full-text dell'hybrid search
# (usa index_name e index_type definiti nella HybridSearchConfig)
knowledge_base_vs.apply_hybrid_search_index()
print("All indexes created!")
Gli indici HNSW accelerano la ricerca per similarità vettoriale; apply_hybrid_search_index() crea invece l’indice GIN kb_tsv_index sulla colonna content_tsv della knowledge base, senza il quale la parte full-text dell’hybrid search funzionerebbe ma scandirebbe tutte le righe.
Che cos’è un indice GIN? GIN sta per Generalized Inverted Index e funziona come l’indice analitico in fondo a un libro: per ogni parola conserva l’elenco delle righe che la contengono. Quando la full-text search cerca “space exploration”, Postgres non legge tutte le righe una per una: apre la voce “space”, apre la voce “exploration” e incrocia le due liste. È lo stesso tipo di indice che si usa anche per interrogare le colonne JSONB.
Per approfondire l’argomento, si può consultare la documentazione ufficiale di pgvector.
7. Il Memory Manager
Arrivati a questo punto possiamo istanziare il Memory Manager, che, come abbiamo detto, astrae tutte le operazioni con cui leggiamo e scriviamo informazioni nel database, nascondendo la complessità delle query SQL e delle operazioni sui vector store dietro un’interfaccia uniforme.
È una singola classe che gestisce sette tipi di memoria con lo stesso pattern read/write:
| Tipo di memoria | Storage | Metodo di scrittura | Metodo di lettura |
|---|---|---|---|
| Conversational | tabella SQL | write_conversational_memory() | read_conversational_memory() |
| Knowledge Base | vector store | write_knowledge_base() | read_knowledge_base() |
| Workflow | vector store | write_workflow() | read_workflow() |
| Toolbox | vector store | write_toolbox() | read_toolbox() |
| Entity | vector store | write_entity() | read_entity() |
| Summary | vector store | write_summary() | read_summary_memory(), read_summary_context() |
| Tool Log | tabella SQL | write_tool_log() | read_tool_logs() |
Riceve la connessione psycopg e deve conoscere tutti gli store a cui gli serve accedere: la tabella SQL per la memoria conversazionale, i PGVectorStore per le altre e la tabella dei tool log.
Partiamo dalla parte vettoriale, che copre cinque delle sette memorie:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
from psycopg.types.json import Json
class MemoryManager:
"""CRUD abstraction over all memory stores (PostgreSQL tables + pgvector stores)."""
# Descrizione e istruzioni d'uso restituite all'LLM insieme ai dati
DESCRIPTORS = {
"conversational": (
"Conversational Memory (episodic memory)",
"Contains the chat history of the current thread, in chronological order.",
"Use it to keep continuity with what has already been said in this thread.",
),
"knowledge_base": (
"Knowledge Base Memory (semantic memory)",
"Contains domain knowledge ingested from external sources. "
"Query this memory with natural language questions about the domain.",
"Use the retrieved passages as factual grounding. Cite the source metadata "
"when relevant. Do not invent information that is not present here.",
),
"workflow": (
"Workflow Memory (procedural memory)",
"Contains sequences of steps already executed by the agent, with their outcome.",
"Reuse a past workflow when the current task resembles it. Prefer the ones that succeeded.",
),
"toolbox": (
"Toolbox Memory (procedural memory)",
"Contains the tools available to the agent and what each one is for.",
"Select the tool whose description matches the current step. Do not invent tools.",
),
"entity": (
"Entity Memory (semantic memory)",
"Contains profiles of people, places and systems met in past interactions.",
"Use these profiles to resolve references and to personalize the answer.",
),
"summary": (
"Summary Memory (semantic memory)",
"Contains condensed summaries of long conversations.",
"Use the summaries to recover context without reading the whole history.",
),
"tool_log": (
"Tool Log (audit trail)",
"Contains raw input/output and execution status of past tool calls.",
"Use it to check whether a tool has already been called and how it failed.",
),
}
def __init__(self, conn, conversation_table, knowledge_base_vs, workflow_vs,
toolbox_vs, entity_vs, summary_vs, tool_log_table):
self.conn = conn
self.conversation_table = conversation_table
self.tool_log_table = tool_log_table
self._stores = {
"knowledge_base": knowledge_base_vs,
"workflow": workflow_vs,
"toolbox": toolbox_vs,
"entity": entity_vs,
"summary": summary_vs,
}
def _format(self, kind: str, body: str) -> str:
"""Wrap the data with the memory description the LLM needs in order to use it."""
name, description, usage = self.DESCRIPTORS[kind]
return (
f"MEMORY TYPE: {name}\n\n"
f"DESCRIPTION: {description}\n\n"
f"USAGE: {usage}\n\n"
f"{body}"
)
# --- Vector stores: same read/write pattern for five memories ---
def _write_vector(self, kind, text, metadata=None):
ids = self._stores[kind].add_texts([text], metadatas=[metadata or {}])
return ids[0]
def _read_vector(self, kind, query, k=3, filter=None):
docs = self._stores[kind].similarity_search(query, k=k, filter=filter)
if not docs:
return self._format(kind, "RETRIEVED PASSAGES:\n(no result)")
passages = "\n".join(
f"[{i}] {doc.page_content} ({doc.metadata})"
for i, doc in enumerate(docs, start=1)
)
return self._format(kind, f"RETRIEVED PASSAGES:\n{passages}")
def write_knowledge_base(self, text, metadata=None):
return self._write_vector("knowledge_base", text, metadata)
def read_knowledge_base(self, query, k=3, filter=None):
return self._read_vector("knowledge_base", query, k=k, filter=filter)
def write_workflow(self, text, metadata=None):
return self._write_vector("workflow", text, metadata)
def read_workflow(self, query, k=3, filter=None):
return self._read_vector("workflow", query, k=k, filter=filter)
def write_toolbox(self, text, metadata=None):
return self._write_vector("toolbox", text, metadata)
def read_toolbox(self, query, k=3):
return self._read_vector("toolbox", query, k=k)
def write_entity(self, text, metadata=None):
return self._write_vector("entity", text, metadata)
def read_entity(self, query, k=3):
return self._read_vector("entity", query, k=k)
def write_summary(self, text, metadata=None):
return self._write_vector("summary", text, metadata)
def read_summary_memory(self, query, k=3):
return self._read_vector("summary", query, k=k)
def read_summary_context(self, thread_id, k=3):
"""Condensed context of a thread: the latest messages become the query."""
rows = self._recent_messages(thread_id, limit=5)
query = "\n".join(content for _role, content, _ts in rows)
if not query:
return ""
return self.read_summary_memory(query, k=k)
Le cinque memorie vettoriali condividono lo stesso pattern, quindi lettura e scrittura passano da due soli metodi privati (_write_vector e _read_vector) e i metodi pubblici che scelgono lo store. _format() è invece ciò che rende l’agente memory-aware: incolla al dato la descrizione della memoria e le istruzioni d’uso, cioè esattamente il blocco che vedremo nell’output più avanti.
Restano le due tabelle SQL, dove le query le scriviamo noi:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# --- SQL tables: conversational memory and tool log ---
def write_conversational_memory(self, thread_id, role, content, metadata=None):
with self.conn.cursor() as cur:
cur.execute(
f"""
INSERT INTO {self.conversation_table} (thread_id, role, content, metadata)
VALUES (%s, %s, %s, %s)
RETURNING id
""",
(thread_id, role, content, Json(metadata or {})),
)
memory_id = cur.fetchone()[0]
self.conn.commit()
return memory_id
def _recent_messages(self, thread_id, limit=20):
"""Last `limit` messages of a thread, returned in chronological order."""
with self.conn.cursor() as cur:
cur.execute(
f"""
SELECT role, content, timestamp
FROM {self.conversation_table}
WHERE thread_id = %s
ORDER BY timestamp DESC
LIMIT %s
""",
(thread_id, limit),
)
return list(reversed(cur.fetchall()))
def read_conversational_memory(self, thread_id, limit=20):
rows = self._recent_messages(thread_id, limit)
body = "\n".join(
f"[{ts:%Y-%m-%d %H:%M:%S}] {role}: {content}" for role, content, ts in rows
) or "(no result)"
return self._format("conversational", f"CONVERSATION HISTORY:\n{body}")
def write_tool_log(self, thread_id, tool_name, tool_input=None, tool_output=None,
status="success", error_message=None, duration_ms=None,
metadata=None):
with self.conn.cursor() as cur:
cur.execute(
f"""
INSERT INTO {self.tool_log_table}
(thread_id, tool_name, tool_input, tool_output,
status, error_message, duration_ms, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(thread_id, tool_name, Json(tool_input or {}), tool_output,
status, error_message, duration_ms, Json(metadata or {})),
)
log_id = cur.fetchone()[0]
self.conn.commit()
return log_id
def read_tool_logs(self, thread_id, limit=10, only_errors=False):
sql = f"""
SELECT tool_name, tool_input, tool_output, status, error_message,
duration_ms, timestamp
FROM {self.tool_log_table}
WHERE thread_id = %s
"""
if only_errors:
sql += " AND status <> 'success'" # usa l'indice parziale
sql += " ORDER BY timestamp DESC LIMIT %s"
with self.conn.cursor() as cur:
cur.execute(sql, (thread_id, limit))
rows = cur.fetchall()
lines = []
for name, tool_input, tool_output, status, error, duration, ts in reversed(rows):
detail = error if status != "success" else tool_output
timing = f" in {duration} ms" if duration is not None else ""
lines.append(f"[{ts:%H:%M:%S}] {name}({tool_input}) -> {status}{timing}\n {detail}")
return self._format("tool_log", "TOOL EXECUTIONS:\n" + ("\n".join(lines) or "(no result)"))
Le letture ordinano per timestamp DESC con LIMIT — cioè prendono i record più recenti — e poi invertono le righe in Python per restituirle in ordine cronologico: è il modo in cui usiamo gli indici creati nella sezione 4, compreso l’indice parziale su status quando chiediamo solo i fallimenti.
Istanziamo il manager:
1
2
3
4
5
6
7
8
9
10
11
12
# Initialize the MemoryManager instance
# Note: Uses SQL table for conversational memory, vector stores for others
memory_manager = MemoryManager(
conn=database_connection,
conversation_table=CONVERSATION_HISTORY_TABLE,
knowledge_base_vs=knowledge_base_vs,
workflow_vs=workflow_vs,
toolbox_vs=toolbox_vs,
entity_vs=entity_vs,
summary_vs=summary_vs,
tool_log_table=TOOL_LOG_HISTORY_TABLE,
)
È proprio questo il vantaggio dell’astrazione: l’agente continua a chiamare
write_knowledge_base()/read_knowledge_base()senza sapere se sotto c’è una tabella SQL, un vector store o un database diverso. A cambiare è l’implementazione dei metodi e lo strato di store, non il contratto del Memory Manager.
8. Scrittura nella knowledge base
Proviamo ad usare il Memory Manager attraverso esecuzione di read e write.
Popoliamo la knowledge base di ArxivScout con dei paper di arXiv presi da Hugging Face. Usiamo il dataset nick007x/arxiv-papers.
1
2
3
4
from datasets import load_dataset
from itertools import islice
ds = load_dataset("nick007x/arxiv-papers", split="train", streaming=True)
Per ogni paper estraiamo i campi chiave (titolo, subjects, abstract, data di pubblicazione e arXiv ID), concateniamo titolo, subjects e abstract in un unico testo ricercabile e scriviamo il tutto nella knowledge base, cioè nella memoria semantica dell’agente. I campi estratti vengono conservati anche come metadati, utili per il filtraggio e per l’attribuzione della fonte.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
for paper in islice(ds, 100):
# extract the key fields
title = (paper.get("title") or "").strip()
abstract = (paper.get("abstract") or "").strip()
subjects = (paper.get("subjects") or paper.get("primary_subject") or "").strip()
submission_date = (paper.get("submission_date") or "").strip()
# skip empty records
if not (title or abstract or subjects):
continue
# concatenate the key fields containing context for semantic search
text = "\n".join([part for part in (title, subjects, abstract) if part])
memory_manager.write_knowledge_base(
text=text,
metadata={
"arxiv_id": paper.get("arxiv_id"),
"title": title,
"subjects": subjects,
"abstract": abstract,
"submission_date": submission_date,
},
)
La scrittura nella knowledge base esegue due operazioni: crea la rappresentazione vettoriale del testo, che è ciò che rende possibile la ricerca semantica sulle righe della tabella, e conserva i metadati nella stessa riga.
Ogni riga contiene quindi sia i metadati sia il vettore corrispondente al contenuto.
Con lo schema che abbiamo definito in StoreManager, arxiv_id, subjects e submission_date finiscono in colonne tipizzate filtrabili; il resto (ad esempio title e abstract come attributi di metadata) confluisce nella colonna JSONB langchain_metadata gestita da PGVectorStore.
Si noti la scelta di cosa mandare nell’embedding: titolo, subjects e abstract finiscono nel testo vettorizzato perché sono ciò su cui vogliamo fare ricerca semantica, mentre
arxiv_idesubmission_daterestano nei metadati (colonne tipizzate), perché servono a filtrare e citare, non a definire il significato del documento. È una decisione di modellazione della memory unit, e influenza direttamente la qualità del retrieval.
9. Lettura dalla knowledge base
Leggiamo dalla memoria knowledge base tramite read_knowledge_base, cercando le righe semanticamente simili alla nostra query.
1
2
results = memory_manager.read_knowledge_base(query="space exploration")
print(results)
Poiché ogni riga della tabella di memoria semantica contiene la rappresentazione vettoriale del testo, ci aspettiamo che le righe restituite siano semanticamente vicine all’espressione “space exploration”, anche quando non contengono letteralmente quelle parole.
Come è possibile che le righe restituite siano semanticamente vicine all’espressione “space exploration” se non contengono letteralmente quelle parole?
Il merito è del modello di embedding: query e documenti vengono proiettati nello stesso spazio vettoriale, dove la vicinanza — qui misurata con la distanza coseno — riflette la somiglianza di significato, non la sovrapposizione di parole. L’idea di codificare il significato in vettori è stata resa popolare da word2vec, che però produce un vettore per singola parola;
paraphrase-mpnet-base-v2è invece un sentence transformer addestrato su coppie di parafrasi, proprio perché frasi che dicono la stessa cosa con parole diverse finiscano vicine.
L’output non è però un semplice elenco di passaggi. Contiene anche l’indicazione del tipo di memoria da cui stiamo leggendo, una descrizione di che cosa contiene quella memoria e di come vada interrogata, e le istruzioni su come utilizzarne le informazioni. La struttura è di questo tipo:
1
2
3
4
5
6
7
8
9
10
11
12
MEMORY TYPE: Knowledge Base Memory (semantic memory)
DESCRIPTION: Contains domain knowledge ingested from external sources.
Query this memory with natural language questions about the domain.
USAGE: Use the retrieved passages as factual grounding. Cite the source
metadata when relevant. Do not invent information that is not present here.
RETRIEVED PASSAGES:
[1] <title> / <subjects> / <abstract> (arxiv_id, submission_date)
[2] <title> / <subjects> / <abstract> (arxiv_id, submission_date)
[3] <title> / <subjects> / <abstract> (arxiv_id, submission_date)
Questi metadati descrittivi sono utili per l’LLM perché stiamo costruendo agenti memory-aware, quindi l’agente deve essere consapevole dei tipi di memoria che possiede e di come usarli. Descrizione e istruzioni d’uso restituite insieme ai dati sono proprio il meccanismo con cui gli forniamo questa consapevolezza.
In questo modo il context engineering viene applicato al retrieval.
Conclusioni
Abbiamo costruito l’infrastruttura di memoria che sta sotto a qualsiasi agente serio: memory store persistenti e differenziati per tipo di memoria, dati modellati per un recupero efficiente e un Memory Manager che orchestra lettura e scrittura durante l’esecuzione.
I concetti da portarsi dietro sono i seguenti:
- il Memory Layer sostituisce il Data Layer nell’agent stack e contiene Memory Core e Memory Manager;
- il Memory Manager è un’astrazione CRUD sopra il database, e isola l’agente dai dettagli di storage;
- le operazioni di memoria possono essere deterministiche o agent-triggered, e le due modalità vanno combinate;
- la memory unit è l’unità atomica di memoria, tipicamente una riga di tabella;
- il context engineering massimizza il valore di ogni token, privilegiando il rapporto segnale/rumore;
- la memory engineering governa l’intero memory lifecycle ed è l’intersezione di discipline consolidate;
- un agente memory-aware non si limita ad avere memoria: sa quali memorie possiede, sa interrogarle da solo e ragiona sul proprio ciclo di vita della memoria.
Infine, ogni scelta di progettazione è un compromesso tra accuratezza, latenza, costo e affidabilità: non esiste la configurazione perfetta, esiste quella giusta per il proprio caso d’uso.