LLMs Intermedio 18 min de lectura

Memoria persistente para NPCs LLM: vector databases

Cómo hacer que un NPC recuerde la conversación del jugador en sesiones pasadas. Embeddings, similarity search y vector stores (Chroma, Qdrant, sqlite-vss) sin jerga.

Publicado: · Por Juanjo "Banyo" López

0. Introducción

El LLM no recuerda nada entre sesiones. Tu NPC tuvo una conversación memorable con el jugador el martes; el viernes, el jugador vuelve, y el NPC saluda como si nunca lo hubiera visto. El sliding window del tutorial anterior funciona dentro de una conversación, no entre conversaciones distintas.

La respuesta intuitiva es: “guardo el historial completo en un JSON y lo paso entero como contexto cada vez”. Mala idea. En diez sesiones tienes 20 000 tokens de input por turno, latencia de seis segundos y una factura que no cuadra. Y peor: el modelo se distrae con detalles irrelevantes en lugar de enfocarse en lo que importa ahora.

La solución estándar es otra: guardar los eventos importantes como embeddings (vectores numéricos) en una base de datos vectorial. Cuando el NPC necesita recordar, busca los recuerdos más similares al momento actual por significado, no por palabra exacta, y los inyecta en el prompt. Trae solo lo relevante.

Casos típicos:

  • NPC compañero de aventura que recuerda promesas, traiciones y bromas internas.
  • Mercaderes que reconocen tratos previos y ajustan precios o tono.
  • Sistemas de reputación con memoria detallada por NPC, no un número global.

1. Demo

Espacio vectorial de recuerdos del NPC Cada recuerdo es un punto en un espacio de N dimensiones (aquí proyectado a 2D). Cuando el NPC piensa en la situación actual, busca los puntos más cercanos.

2. Concepto y arquitectura

Un vector database guarda textos como vectores numéricos (embeddings). Para “recordar” algo relevante, conviertes la situación actual en otro vector y buscas los más cercanos por similitud. No es búsqueda exacta: es búsqueda por significado.

2.1 ¿Qué es un embedding y por qué lo necesito?

Un modelo de embeddings (por ejemplo text-embedding-3-small de OpenAI, o all-MiniLM-L6-v2 local) toma un texto y devuelve un vector de N números reales. Típicamente N es 384, 768 o 1536 dimensiones. Cada texto se convierte en un punto en ese espacio.

La propiedad que nos importa: textos parecidos en significado quedan cerca en el espacio vectorial. Cerca según una métrica, normalmente cosine similarity. No estamos comparando letras, sino conceptos.

"el jugador me robó la espada"   →  [0.21, -0.04, 0.88, ...]   (1536 dims)
"el héroe me quitó el arma"      →  [0.19, -0.07, 0.85, ...]
"el cielo es azul"               →  [-0.91, 0.34, -0.12, ...]

Las dos primeras frases son sinónimas para el modelo: sus vectores caen casi en el mismo punto. La tercera, en otro continente del espacio. Esto es lo que un sistema de keyword search no puede hacer.

Espacio vectorial 2D (proyección)
dim 1dim 2cluster: “traición”cluster: “comercio”cluster: “clima”queryradio = umbral de similitud

Cada recuerdo es un punto. El cluster azul agrupa frases sobre traición; el query (rosa, pulsando) trae los vecinos dentro del radio de búsqueda.

2.2 ¿Por qué no me alcanza con buscar por palabras clave?

Imagina que el jugador dijo en la sesión 3: “te debo una, Maerwyn”. En la sesión 7 te pregunta: “¿qué tienes contra mí?”. Si buscas por palabras clave, ninguna coincide. Si buscas por embeddings, ambas frases caen en la región semántica de “relación interpersonal entre jugador y NPC”, y el recuerdo aflora.

Esa es la diferencia entre buscar texto y buscar significado. Para diálogos, los embeddings ganan por goleada.

2.3 ¿Cómo se ve el pipeline completo?

Pipeline: escritura (insert) y lectura (query)
evento”player robó”embed()→ vectorinsertVector DBChroma / Qdrantsqlite-vsssituación”qué tienes contra mí”embed()→ query vecquerytop-Krecuerdosrelevantes→ prompt LLM

Camino superior: evento → embed → store. Camino inferior: situación actual → embed → nearestNeighbors → top-K inyectado en el prompt.

Dos caminos: escribir (cada vez que pasa algo relevante en el juego) y leer (cada vez que el NPC necesita responder). La base de datos vive entre sesiones; el LLM no necesita haber visto nunca esos recuerdos.

2.4 Vector DB local vs cloud: ¿cuál eliges?

OpciónProsContras
sqlite-vssUn archivo, sin servidor. Empaquetable con el juego.Rendimiento mediocre por encima de ~100k vectores.
ChromaPythónico, fácil de prototipar.Requiere proceso Python corriendo aparte.
QdrantRust, rápido, escala bien. Filtros por metadata excelentes.Binario externo o servicio cloud.
Pinecone (cloud)Cero instalación, gestionado.Costo recurrente, latencia de red, vendor lock-in.

Para un juego single-player offline, sqlite-vss o un índice en memoria respaldado por SQLite es lo más limpio. Para un juego online con muchos jugadores compartiendo NPCs, Qdrant o Pinecone. Si el juego no necesita persistencia entre sesiones (un FPS sin save game narrativo), no uses nada.

2.5 ¿Qué metadata guardo junto al vector?

El vector solo no te alcanza. Junto a cada embedding guardas:

  • timestamp — cuándo ocurrió el evento. Necesario para el decay temporal.
  • npcId — qué NPC fue testigo o protagonista.
  • playerId — clave para no mezclar jugadores en multiplayer.
  • importance — escala 1–5. Una promesa pesa más que un comentario sobre el clima.
  • tags — lista de etiquetas (combate, traición, quest:cap2, mood:enojado).
  • saveSlot — link al slot de save game (los recuerdos no se mezclan entre partidas).

La metadata permite prefiltrar antes de hacer similarity search: “busca recuerdos de Maerwyn sobre traiciones en este save”, no “busca en toda la base”.

2.6 Decay de relevancia: el olvido como feature

Los humanos olvidamos lo viejo. Tu NPC también debería. No borres recuerdos antiguos: solo bájales el peso. Combinar tres factores funciona bien:

finalScore = cosineSim(query, memoria) * importance * exp(-edad / TAU)

Donde TAU es la constante temporal (en horas de juego o tiempo real, según prefieras). Si TAU = 24h, un recuerdo de hace 24 horas pesa 1/e ≈ 0.37 de su valor original. De hace 72h, ~0.05. Aún recuperable si la similitud es altísima, pero ya no aparece por defecto.

Top-K ranking con decay temporal
#recuerdocosSimimpedaddecayfinal →1”te debo una, Maerwyn”0.8256h0.783.202”me regaló una poción”0.7132h0.921.963”juraste protegerme”0.88448h0.140.494”hablamos del clima”0.3111h0.960.305”discutimos hace meses”0.794720h~0.000.01k=2 inyectado al promptLLM”discutimos hace meses” tenía buena sim, pero el decay lo expulsa del top.

cosineSim × importance × exp(−edad/TAU). Una sim alta pierde peso si el recuerdo es viejo; un recuerdo importante reciente sube.

2.7 ¿Cómo decido qué guardar en memoria?

No todo turno se persiste. Si guardas cada línea de diálogo, en una hora tienes 500 vectores irrelevantes y el ruido tapa la señal. Heurísticas que funcionan:

  • Eventos explícitos del sistema: el NPC murió, una quest se completó, el jugador entregó un objeto. El motor del juego dispara un Remember(...) deliberado.
  • Cambios de mood detectados: tras la respuesta del LLM, una segunda llamada (function calling) pregunta “¿esta interacción cambió la opinión del NPC sobre el jugador?”. Si la respuesta es sí, persistes el turno.
  • Acciones del jugador con consecuencia mecánica: robo, regalo, traición, perdón. Lo que ya tienes mapeado en el sistema de reputación.

Una buena regla: persiste lo que un guionista escribiría en un dossier del personaje, no lo que tomarías como nota al margen.

2.8 Privacidad y aislamiento entre jugadores

En un juego online, nunca mezcles embeddings de jugadores distintos en el mismo índice consultable. Aunque sean el mismo NPC global, cada jugador tiene su propia relación con él. Soluciones:

  • Namespace por playerId dentro de Qdrant/Pinecone (es nativo).
  • Tabla aparte por jugador en sqlite-vss.
  • Filtro obligatorio WHERE playerId = ? en cada query, validado en el servidor (no confíes en el cliente).

Si la quest de un jugador se filtra al diálogo de otro, no es un bug gracioso: es una fuga de datos personales.

3. Pseudocódigo

struct Memory
    text: String
    embedding: Vector<Float>
    timestamp: Float
    importance: Float
    tags: List<String>

function remember(store: VectorStore, event: String, importance: Float, tags: List<String>)
    vec = embed(event)
    store.insert(Memory(event, vec, now(), importance, tags))

function recall(store: VectorStore, situation: String, k: Int = 5) -> List<Memory>
    vec = embed(situation)
    # pedimos más candidatos de los que devolveremos: el rerank los baja
    candidates = store.nearestNeighbors(vec, k * 3)
    scored = []
    for each m in candidates
        ageFactor   = exp(-(now() - m.timestamp) / TAU)
        finalScore  = cosineSim(vec, m.embedding) * m.importance * ageFactor
        scored.append((m, finalScore))
    sort scored by finalScore desc
    return top k from scored

function injectIntoPrompt(systemPrompt: String, memories: List<Memory>) -> String
    if memories.empty: return systemPrompt
    block = "\n\nRecuerdos relevantes:\n"
    for each m in memories
        block += "- " + m.text + "\n"
    return systemPrompt + block

Tres ideas: traer más candidatos de los que vas a usar para que el rerank por importance y decay tenga material, el cosineSim es lo único que pide al vector store, y el formato de inyección en el prompt es texto plano, no JSON.

4. Implementación en Unity / C#

Prototipo con índice en memoria respaldado por SQLite. Para producción cambias el List<Memory> por sqlite-vss o un cliente HTTP a Qdrant; la API pública no cambia.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using UnityEngine;

[Serializable]
public class Memory {
    public string text;
    public float[] embedding;
    public long timestampMs;
    public float importance;
    public string[] tags;
}

public class MemoryStore : MonoBehaviour {
    public float tauHours = 24f;
    public int candidatesMultiplier = 3;

    readonly List<Memory> store = new(); // en producción: sqlite-vss / Qdrant
    [SerializeField] EmbeddingClient embedder; // wrapper a OpenAI text-embedding-3-small

    public async Task Remember(string text, float importance, string[] tags) {
        var emb = await embedder.Embed(text);
        store.Add(new Memory {
            text = text, embedding = emb,
            timestampMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
            importance = importance, tags = tags
        });
    }

    public async Task<List<Memory>> Recall(string situation, int k = 5) {
        var query = await embedder.Embed(situation);
        var now   = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
        var tau   = tauHours * 3600f * 1000f;

        return store
            .Select(m => new {
                m,
                score = CosineSim(query, m.embedding)
                      * m.importance
                      * Mathf.Exp(-(now - m.timestampMs) / tau)
            })
            .OrderByDescending(x => x.score)
            .Take(k)
            .Select(x => x.m)
            .ToList();
    }

    static float CosineSim(float[] a, float[] b) {
        float dot = 0f, na = 0f, nb = 0f;
        for (int i = 0; i < a.Length; i++) {
            dot += a[i] * b[i];
            na  += a[i] * a[i];
            nb  += b[i] * b[i];
        }
        return dot / (Mathf.Sqrt(na) * Mathf.Sqrt(nb) + 1e-8f);
    }
}

El EmbeddingClient es un wrapper HTTP equivalente al NpcLlmAgent del tutorial anterior: POST a /v1/embeddings, lees data[0].embedding. Para offline puro, lo cambias por un modelo ONNX corriendo en Unity Sentis.

5. En otros engines

  • Godot: SQLite GDExtension + cliente HTTP (HTTPRequest) hacia OpenAI/Anthropic para embeddings. La lógica de Recall es la misma; cambia solo cómo persistes.
  • Unreal: plugin SQLite (varios en el Marketplace) + FHttpModule para embeddings. Si haces inferencia local de embeddings, NNI o un wrapper de ONNX Runtime.
  • JavaScript / Electron: chromadb-js o lancedb (este último es serverless, un archivo). El cliente de embeddings de OpenAI tiene SDK oficial para Node.

El patrón es idéntico en todos: una capa que persiste vectores + metadata, un cliente de embeddings, y la función de scoring combinada. Lo único que cambia es la API del store.

6. Quiz

Pon a prueba lo que entendiste

Responde una por una. La explicación aparece al elegir, correcta o no.

  1. Tu NPC trae al diálogo un recuerdo irrelevante (el jugador habla de pociones y el NPC saca a relucir una pelea de hace dos sesiones). ¿Qué tunear primero?

  2. ¿Por qué no almacenar simplemente todo el historial en JSON y dárselo al LLM cada turno?

  3. ¿Cuándo NO usar una vector DB para memoria de NPC?

  4. Tu juego es offline, sin conexión obligatoria. ¿Qué modelo de embeddings eliges?