RAG para NPCs: dándole conocimiento del mundo del juego
El NPC habla del lore como si lo hubiera vivido sin alucinar. Retrieval-Augmented Generation: trozar tu wiki interna, buscarla por similitud e inyectarla en el prompt.
Publicado: · Por Juanjo "Banyo" López
0. Introducción
Tu juego tiene 200 páginas de lore: historia del reino, geografía, especies, quests, traiciones, genealogías. Quieres que un NPC bibliotecario hable de todo eso sin inventarse nada. Y aquí empieza el problema.
Si metes las 200 páginas en el system prompt, pagas decenas de miles de tokens en cada llamada, la latencia se dispara y el modelo pierde foco entre tanto ruido. Si no las metes, el NPC se inventa la historia del reino, le pone otro nombre al rey, sitúa la capital en otra montaña, y rompe tu canon en la primera conversación.
La solución se llama RAG (Retrieval-Augmented Generation): almacenas el lore como trozos indexados, y cuando el jugador pregunta, recuperas solo los trozos relevantes y los inyectas en el prompt. El modelo pasa de “inventar” a “citar”.
En este tutorial vas a ver:
- Por qué meter todo el lore en el system prompt no escala.
- Cómo funciona el pipeline RAG: chunk, embed, retrieve, augment, generate.
- Cómo implementarlo en Unity con un vector store local y un cliente LLM.
1. Demo
2. Concepto y arquitectura
RAG (Retrieval-Augmented Generation) es un patrón donde, antes de pedir al LLM una respuesta, buscas en un corpus externo (tu wiki, tus quest logs) los trozos relevantes y los anexas al prompt. El modelo deja de “inventar” y empieza a “citar”.
2.1 ¿Por qué no meter todo el lore en el system prompt?
Cuatro razones que se refuerzan entre sí:
- Context window finita. Aunque los modelos modernos aceptan 200K tokens, llenar la ventana de lore deja menos espacio para el historial de la conversación y dispara la latencia.
- Costo lineal en tokens. Si mandas 50 000 tokens de lore en cada turno, multiplicas el costo por cada interacción del jugador. Una sesión de 30 turnos puede costar 100× lo que costaría con RAG.
- Attention dispersa. El modelo “presta atención” a todo el contexto, pero esa atención se diluye cuanto más texto hay. Con la wiki entera dentro, hasta los modelos top pierden información en el medio (el famoso lost in the middle).
- Latencia inflada. Procesar el input es la parte más lenta de una llamada. Más input = más tiempo de espera antes de que el NPC empiece a hablar.
Lo peor: aunque tu wallet aguante, el modelo está invirtiendo tiempo y atención buscando lo relevante entre el ruido. RAG hace ese trabajo antes de la llamada, fuera del modelo.
2.2 ¿Qué hace RAG exactamente?
El pipeline tiene cinco pasos. Los tres primeros (chunk, embed, indexar) corren una vez al cargar el juego o cuando editas el lore. Los dos últimos (retrieve, augment+generate) corren en cada turno de diálogo.
Offline: la wiki se trocea, embebe e indexa. Runtime: cada pregunta se embebe, busca top-k en el vector store, augmenta el prompt y se manda al LLM.
Un embedding es un vector (típicamente 768 o 1536 dimensiones) que representa el “significado” de un texto. Dos textos con significado similar producen vectores cercanos en ese espacio. Buscar por similitud coseno entre el vector de la pregunta y los vectores de tus chunks recupera los más relevantes semánticamente, no por coincidencia de palabras.
2.3 Chunking: el detalle que decide el éxito
Aquí se gana o se pierde la mitad de la calidad. No embedeas la wiki entera (sería un solo vector inútil) ni cada palabra (perderías contexto). Lo que embedeas son chunks: trozos del tamaño justo para tener significado autocontenido.
Tres estrategias, de simple a sofisticada:
- Por estructura. Un chunk por sección de la wiki, por entrada de bestiario, por quest. Es lo más natural si tu lore ya está organizado: respeta los límites semánticos que tú mismo definiste como diseñador.
- Por tokens fijos con overlap. Cortas cada 400 tokens con un solapamiento de 50 tokens entre chunks consecutivos. El overlap evita que un concepto importante quede partido a la mitad entre dos chunks (uno tendría la pregunta, el siguiente la respuesta, y ninguno por separado serviría).
- Semantic chunking. Cortas donde detectas un cambio de tema, midiendo la distancia entre embeddings de oraciones consecutivas. Es lo más caro de procesar pero da los mejores chunks. Para juegos suele ser overkill.
El documento se trocea en chunks de tamaño fijo. La zona oscura entre chunks consecutivos es el overlap: protege contra cortes que partan un concepto a la mitad.
Valores razonables para empezar: chunks de 300-500 tokens, overlap de 50 tokens. Ajusta midiendo qué tan a menudo el modelo responde con info incompleta.
2.4 ¿Cuántos chunks recuperar (el famoso “k”)?
El parámetro k del top-k retrieval suele estar entre 3 y 8.
- k pequeño (3-4): bajo costo, baja latencia, riesgo de no traer el chunk que necesitas si tu embedding de la pregunta no lo capturó perfectamente.
- k grande (8-12): más cobertura, pero también más ruido. Chunks irrelevantes que confunden al modelo y elevan el costo de input.
Los chunks se ordenan por similitud coseno con la pregunta. Los top-K (los más cercanos) viajan al prompt; el resto se descarta.
La regla práctica: empieza en k=5. Subes hasta que la calidad mejore notablemente; bajas al primer signo de respuestas confusas o contradictorias. Si tu wiki es muy variada, sube; si está muy enfocada, baja.
2.5 ¿Cómo evitar que el NPC repita el chunk como un loro?
Síntoma clásico: el RAG funciona, pero el NPC responde con frases textuales de tu wiki, en tono de manual, sin personalidad. Solución: el system prompt manda más que los chunks. Algo así:
Eres Maerwyn, mercader nómada de 50 años con humor seco.
A continuación tienes información del mundo extraída de tu memoria.
Usa esta información como referencia, pero respóndela en tu propio
tono, en 1-3 frases, como si la recordaras tú. NUNCA cites
textualmente. NUNCA digas "según mis fuentes" o "en la wiki".
[CHUNKS RECUPERADOS AQUÍ]
La instrucción explícita “no cites textualmente” más el tono del personaje obligan al modelo a reformular. Si el NPC sigue robotizándose, prueba reducir k: a veces el problema es que le das demasiado material y se aferra al primer chunk literalmente.
2.6 RAG vs fine-tuning para lore
Pregunta frecuente: ¿no sería mejor entrenar al modelo directamente con el lore? Depende:
| Aspecto | RAG | Fine-tuning |
|---|---|---|
| Editar lore | Inmediato (re-indexar) | Re-entrenar el modelo |
| Costo de setup | Bajo (un vector store) | Alto (datos curados + cómputo) |
| Calidad para canon estricto | Media-alta | Alta |
| Trazabilidad (“¿de dónde sacaste eso?”) | Sí, citas el chunk | No, está en los pesos |
| Recomendado para juego en desarrollo | Sí | No |
| Recomendado para lore congelado y enorme | Mixto | Sí (más RAG encima) |
Para 99% de juegos indie y AA, RAG gana solo. Fine-tuning tiene sentido cuando tu lore es masivo, estable, y necesitas que el modelo lo “internalice” más allá de poder citar fragmentos.
2.7 Re-ranking: el paso opcional que sube calidad
El top-k por similitud coseno es rápido pero tonto: solo mide cercanía vectorial. A veces el chunk #1 por similitud no es el más útil para la pregunta. La solución se llama re-ranking:
- Traes top-k con similitud (rápido, barato).
- Pasas esos k candidatos por un cross-encoder (un modelo pequeño que evalúa pares pregunta-chunk con más cuidado) y los reordenas.
- Te quedas con los top-3 reordenados.
Ganancia típica: 15-30% en calidad subjetiva. Costo: una llamada extra por turno y unos 200-500 ms más de latencia. Para NPCs críticos del juego (el bibliotecario, el sabio) compensa; para NPCs casuales, no.
2.8 ¿Cómo detectar cuando el RAG no tiene la respuesta?
Si el jugador pregunta algo que no está en tu wiki (“¿cuántos hijos tuvo el segundo rey?” cuando solo documentaste al primero), el top-k traerá los chunks “menos malos” disponibles, y el modelo intentará tejer una respuesta con eso. Resultado: invención disfrazada de canon. Catastrófico.
La defensa es chequear el score de similitud del mejor chunk:
- Si
max_score >= threshold: hay info relevante, procedes normal. - Si
max_score < threshold: no hay info; cambias al system prompt de fallback: “No tienes información sobre esto en tu memoria. Responde como NPC que admite no saber, o redirige al jugador a otra fuente del mundo.”
El threshold se calibra a ojo con ejemplos de tu juego: típicamente entre 0.65 y 0.78 para similitud coseno con embeddings modernos. Es el guardrail más importante de un RAG en producción.
3. Pseudocódigo
function indexLore(loreDocs: List<Document>, store: VectorStore)
for each doc in loreDocs
chunks = chunkDocument(doc, size=400, overlap=50)
for each chunk in chunks
vec = embed(chunk.text)
store.insert(chunk.text, vec, metadata=chunk.source)
function answerWithRAG(question: String, store: VectorStore, llm: LlmClient, k: Int = 5) -> String
queryVec = embed(question)
chunks = store.nearestNeighbors(queryVec, k)
if chunks.maxScore < THRESHOLD
# no hay info relevante → fallback honesto
return llm.chat([systemNoInfoPrompt, userPrompt(question)])
context = chunks.map(c => "- " + c.text).join("\n")
augmentedPrompt = systemPrompt + "\n\nConocimiento relevante:\n" + context
return llm.chat([{role: "system", content: augmentedPrompt}, {role: "user", content: question}])
function chunkDocument(doc: Document, size: Int, overlap: Int) -> List<Chunk>
chunks = []
tokens = tokenize(doc.text)
i = 0
while i < tokens.length
end = min(i + size, tokens.length)
chunks.append(Chunk(tokens[i:end], doc.source))
i += size - overlap
return chunks
Tres ideas clave: el embed se llama tanto al indexar como al preguntar (con el mismo modelo, si no, los vectores no son comparables); el threshold convierte el RAG en un sistema honesto que admite ignorancia; el chunking con overlap es lo que evita cortes feos.
4. Implementación en Unity / C#
Clase LoreRAG con un vector store en memoria. Para producción usas SQLite con extensión vectorial, Qdrant o Pinecone; para empezar, un List<> en memoria sobra hasta unos miles de chunks.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using UnityEngine;
public class LoreRAG : MonoBehaviour {
[TextArea(4, 10)] public string systemPrompt =
"Eres Maerwyn, sabio del reino. Usa la info de tu memoria " +
"pero responde en tu propio tono, en 1-3 frases. No cites textualmente.";
[Range(1, 12)] public int topK = 5;
[Range(0f, 1f)] public float scoreThreshold = 0.72f;
public int chunkSize = 400;
public int chunkOverlap = 50;
readonly List<(string text, float[] vec, string source)> store = new();
public EmbeddingClient embed; // wrapper a la API de embeddings
public LlmClient llm; // wrapper al chat completions
public async Task IndexLore(string filePath) {
var text = File.ReadAllText(filePath);
var source = Path.GetFileNameWithoutExtension(filePath);
foreach (var chunk in Chunk(text, chunkSize, chunkOverlap)) {
var vec = await embed.Embed(chunk);
store.Add((chunk, vec, source));
}
}
public async Task<string> Ask(string question) {
var qVec = await embed.Embed(question);
var ranked = store
.Select(c => (c.text, c.source, score: Cosine(c.vec, qVec)))
.OrderByDescending(c => c.score)
.Take(topK)
.ToList();
if (ranked.Count == 0 || ranked[0].score < scoreThreshold) {
var fallback = systemPrompt + "\n\nNo tienes información sobre esto en tu memoria. Admite que no lo sabes, en personaje.";
return await llm.Chat(fallback, question);
}
var context = string.Join("\n", ranked.Select(c => $"- ({c.source}) {c.text}"));
var augmented = systemPrompt + "\n\nConocimiento relevante:\n" + context;
return await llm.Chat(augmented, question);
}
static IEnumerable<string> Chunk(string text, int size, int overlap) {
var tokens = text.Split(' ');
for (int i = 0; i < tokens.Length; i += size - overlap) {
int end = Math.Min(i + size, tokens.Length);
yield return string.Join(' ', tokens, i, end - i);
if (end == tokens.Length) yield break;
}
}
static float Cosine(float[] a, float[] b) {
float dot = 0, na = 0, nb = 0;
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-9f);
}
}
Lo que el prompt augmentado se ve textualmente en tiempo de ejecución cuando un jugador pregunta sobre el reino:
Eres Maerwyn, sabio del reino. Usa la info de tu memoria
pero responde en tu propio tono, en 1-3 frases. No cites textualmente.
Conocimiento relevante:
- (historia.md) El reino de Veldria fue fundado en el año 312 por...
- (geografia.md) La capital, Tarn-Veldra, se sitúa al pie del monte...
- (linajes.md) La dinastía Halrik gobernó hasta el cisma del año 580...
5. En otros engines
- Godot:
FileAccesspara leer la wiki +HTTPRequestpara llamar a la API de embeddings y al LLM. La lógica de chunking, cosine y top-k es C#/GDScript puro, idéntica. - Unreal:
FFileHelper::LoadFileToString+FHttpModulepara HTTP. Para el vector store local, unTArray<FRagChunk>con tu struct propia hace el trabajo hasta varios miles de chunks. - JS / Electron:
LangChain.jsyLlamaIndex.tstraen RAG out-of-the-box con docenas de vector stores integrados. Si tu juego es web o desktop con Electron, usar LangChain te ahorra escribir el pipeline desde cero.
La parte que cambia entre engines es solo el I/O y el cliente HTTP. El pipeline RAG (chunk, embed, retrieve, augment) es el mismo en todos lados.
6. Quiz
Pon a prueba lo que entendiste
Responde una por una. La explicación aparece al elegir, correcta o no.
Tu NPC responde con info correcta del lore pero la repite textual, sin entrar en personaje. ¿Qué ajustas primero?
¿Por qué se chunkea con overlap (los últimos N tokens de un chunk se repiten al inicio del siguiente)?
El RAG trae chunks irrelevantes a la pregunta y el NPC responde con info que no viene a cuento. ¿Qué chequeas primero?
Quieres que el NPC ADMITA cuando no tiene información sobre algo, en lugar de inventársela. ¿Cómo lo logras con RAG?