En mi guía de bases de datos del vault tengo una frase subrayada: PostgreSQL hasta que duela. Una base de datos nueva solo entra cuando hay un problema medido que Postgres no resuelve. Y aun así, entre mis agentes uso o he probado SQLite, Redis, Qdrant, Chroma, Postgres y Neo4j. Este post cuadra esa regla con lo que he construido: qué motor uso para cada trabajo y por qué.
Los nombres de los repos no coinciden con los del portfolio: multi-agente es el "Sistema Multi-Agente", reto es el "Agente de IA", agente-telegram es el "Asistente multiagente" y tutor-clinico es "Mis especialistas: tutor clínico con RAG híbrido".
El mapa
| Motor | Proyecto | Su trabajo |
|---|---|---|
| SQLite | multi-agente, agente-telegram, reto | espejo durable, índice, vectores propios |
| Redis | multi-agente | sesiones con TTL, tickets con TTL, PubSub del WebSocket |
| Qdrant | multi-agente | memoria semántica |
| ChromaDB | reto | índice derivado, se reindexa en cada arranque |
| Postgres, Neo4j, Chroma | tutor-clinico | auditoría, grafo, recuperación |
SQLite: el espejo durable de multi-agente
Las sesiones de multi-agente viven en tres capas: un dict en memoria, Redis con TTL de 24 horas y SQLite en modo WAL como espejo. Cada guardado escribe en Redis y en SQLite. La lectura mira primero la memoria y, si la sesión no está, cae a SQLite y la cachea. Redis no interviene en ese camino: al arrancar, vuelco SQLite a memoria y encima las sesiones de Redis, que son más frescas.
Elegí SQLite porque es librería estándar de Python, sin dependencias nuevas, y con WAL varios workers de uvicorn pueden compartir el mismo fichero. Ningún método lanza hacia fuera: si SQLite falla, se loguea y la sesión sigue viva en las otras capas.
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
created_at REAL,
updated_at REAL,
phase TEXT,
model TEXT,
task TEXT,
data TEXT
);Es una fila por sesión: un blob JSON con todo en data y unas columnas para lo que quiero poder consultar. Hoy esas columnas casi no se aprovechan: solo ordeno por created_at.
Redis: lo que vive poco
En multi-agente Redis hace tres cosas: la capa L2 de las sesiones, con TTL de 24 horas; los tickets, que se guardan con TTL (store_ticket con setex); y el PubSub que alimenta el feed del WebSocket (sessions.emit publica y _stream_via_pubsub escucha). El PubSub es además el uso que más destaca mi nota redis-patrones del vault.
Mi nota del vault dice que Redis es caché primero. En el código eso vale para las sesiones: tienen SQLite detrás porque, pasadas 24 horas o tras reiniciar Redis, se perderían. Los tickets, en cambio, viven solo en Redis, sin espejo durable, y el PubSub es efímero por naturaleza.
Vectores: Qdrant y las dimensiones
La memoria semántica de multi-agente va en Qdrant, en tres colecciones (agent_results, code_patterns, session_memory), con distancia coseno e índices de payload por sesión, experto y ticket.
Aprendí una lección sobre los vectores: el tamaño del vector tiene que coincidir con el del modelo de embeddings (768 dimensiones con nomic-embed-text). Si no coinciden, la inserción se rechaza y una búsqueda que devuelve una lista vacía se parece mucho a "no hay nada parecido". Por eso, hoy el .env fija 768 y embeddings.py avisa de que cambiar de modelo obliga a recrear las colecciones.
SQLite otra vez: mis propios vectores en agente-telegram
En agente-telegram guardo los vectores de los recuerdos en una tabla de SQLite, con el texto, la dimensión y el vector como BLOB:
CREATE TABLE IF NOT EXISTS vectores (
texto TEXT PRIMARY KEY,
dimension INTEGER NOT NULL,
vector BLOB NOT NULL
)La similitud la calculo con numpy: leo las filas, armo una matriz y la multiplico por el vector de la consulta, que ya está normalizado, así que el producto escalar es el coseno. Descarté sqlite-vec: sigue instalado, pero dos pruebas del arnés fallan si se importa, y es una extensión nativa 0.1.x. Para unos cientos de recuerdos el coseno a mano me basta.
El coste es evidente: cada búsqueda lee todas las filas, así que es O(n). Si los recuerdos crecen mucho, ahí es donde dolerá.
La columna dimension está para esto: al buscar descarto los vectores de otra dimensión. Cambiar de modelo sin reindexar daría números sin significado, sin error, que es la misma clase de fallo que el del 1536. Aquí lo evito con un rechazo explícito.
reto: una fuente de verdad y dos derivados
En reto la regla es el ADR-002: el JSON de cada conversación es la fuente completa (con el historial cifrado con Fernet cuando hay clave configurada en CONVERSATIONS_KEY; sin ella se guarda en claro, con un aviso), SQLite es solo un índice (fecha, proveedor, modelo, ruta al JSON, número de mensajes) y ChromaDB es un derivado. Si alguna vez discrepan, gana el JSON. El cifrado cubre solo el campo del historial: fecha, proveedor, modelo y nombre quedan en claro a propósito.
Los ids de Chroma son deterministas, fichero:indice, y en cada arranque se indexan solo los mensajes que faltan, así que reindexar no duplica y el índice se puede recomponer desde los JSON. Según storage.py y rag.py, Chroma guarda el vector y un puntero, no el texto en claro, para que no sea un canal lateral junto al cifrado.
tutor-clinico: Postgres, Neo4j y Chroma con otra exigencia
Aquí el problema es distinto: una auditoría que se pueda enseñar como fiable.
La decisión (ADR-0008) son tres roles: arranque, aplicación y mantenimiento. El rol de la API solo tiene SELECT e INSERT sobre el log de auditoría, sin UPDATE ni DELETE. Cada registro lleva el hash del anterior:
def chain_hash(previous_hash: str, record: str) -> str:
return hashlib.sha256((previous_hash + record).encode("utf-8")).hexdigest()Las inserciones se serializan con un advisory lock de transacción (pg_advisory_xact_lock); sin él, dos turnos simultáneos leerían el mismo hash anterior y la cadena se bifurcaría. Para el borrado de datos de una persona, borro su clave de seudónimo y los registros siguen en la cadena.
Neo4j tiene un modelo deliberadamente simple (ADR-0015): entidades unidas por un único tipo de relación, y consultas de texto fijo. El adaptador rechaza cualquier consulta que no sea una de sus constantes. En Chroma, el scope del usuario va dentro del filtro de la consulta, no se aplica después.
Cómo lo decido ahora
Me quedo con una pregunta para cada dato: si se pierde, ¿qué pasa? Si "se recalcula", va a Redis o a un índice derivado. Si "pierdo historia", a un sitio durable. Y si "no puedo demostrar que nadie lo tocó", a Postgres con roles.