Introducción
La última entrada de este blog es de octubre de 2023. Casi tres años. No hay una historia dramática detrás: hubo trabajo, mudanzas de contexto, y sobre todo un periodo en el que el campo se movía tan rápido que escribir sobre él se sentía como fotografiar un tren en marcha. Cada borrador que empezaba envejecía antes de terminarlo.
Así que decidí volver de la única manera que me convence: con algo construido, no con una promesa de volver a escribir. Este post es sobre ese algo — un chatbot RAG que responde preguntas sobre los posts de este mismo blog, en español o en inglés, citando siempre el post del que sacó la respuesta. Corre completamente autohospedado: sin API de pago, sin secretos en runtime, sin GPU, y vive como demo efímero: se provisiona cuando alguien lo pide, corre unos 20 minutos y se destruye solo.
Hay una simetría que me gusta: para volver a escribir, construí una máquina que lee todo lo que escribí antes.
Y hay algo más honesto que decir de entrada: el proyecto lo construí junto con un asistente de código, en sesiones largas de decisiones y mediciones. Es exactamente el tipo de trabajo sobre el que quiero escribir ahora, así que este post también es una muestra de cómo se ve ese proceso cuando se documenta bien.
Las restricciones definieron el diseño (no el stack)
Antes de la primera línea de código, escribí las reglas. En un proyecto de este tipo las restricciones son el diseño; el stack es apenas la consecuencia:
- $0 de API de pago. LLM y embeddings locales. Nada de facturas por token en un demo público.
- Self-contained. Sin secretos en runtime y sin llamadas externas en producción. Todo lo que el demo necesita está horneado en las imágenes.
- CPU only. Sin GPU. Nada de “funciona pero cuesta como un servidor de entrenamiento”.
- Efímero y con teardown abrupto. Sesión de ~20 minutos, 8 de inactividad, y kill switch. El contenedor puede morir a mitad de un stream: no puede haber estado que perder.
- Stack OSS, imágenes públicas, y solo datos públicos (los posts ya están en internet).
De ahí salió, casi solo, el resto:
| Pieza | Elección |
|---|---|
| App | FastAPI (Python 3.13) + UI vanilla, streaming por SSE |
| LLM | Qwen2.5-0.5B-Instruct vía Ollama, local en el contenedor |
| Embeddings | paraphrase-multilingual-MiniLM-L12-v2 (384-d), local |
| Vector store | Postgres 16 + pgvector, efímero, sembrado al arranque |
| Corpus | 34 posts (28 EN + 6 ES) → 278 chunks de ~400 tokens |
Arquitectura
El demo son tres contenedores en un mismo pod, compartiendo localhost: la app (único con
ingress), Ollama y Postgres/pgvector. La app no lleva autenticación propia — la hace un gateway que
está por delante y que también termina TLS.
Navegador (sesión) ──HTTPS──► Gateway (TLS + auth de sesión)
│ ingress interno :8080
▼
FastAPI (app)
│
Pod efímero — 2 vCPU / 4 GiB, comparten localhost
├─ embed de la pregunta ──► sentence-transformers (multilingual MiniLM-L12-v2)
├─ top-3 por coseno ──────► pgvector / Postgres 16 (278 chunks / 34 posts)
└─ prompt fundamentado ───► Ollama (Qwen2.5-0.5B-Instruct)
│
▼ tokens SSE + citas
Navegador
La parte cara — traer los posts, limpiarlos, trocearlos y calcular embeddings — no ocurre nunca en
runtime. Ocurre en CI, y su resultado es un dump.sql que se hornea dentro de la imagen de la base
de datos. Cuando el demo arranca, Postgres simplemente carga el dump y el corpus está listo. Es la
diferencia entre un arranque de segundos y uno de minutos, y en un demo que vive 20 minutos esa
diferencia lo es todo.
El flujo por mensaje es el clásico de RAG, sin adornos:
- Se embebe la pregunta (384-d, normalizada).
- Top-3 por similitud coseno en pgvector, filtrando por un umbral.
- Se arma el prompt: system fundamentado + contexto recuperado + historial + pregunta.
- Qwen genera en streaming, la app relaya token a token por SSE y adjunta las citas (los posts de los chunks usados, deduplicados por URL).
La consulta de retrieval es tan simple como debería ser:
SELECT url, title, lang, published, chunk_index, content,
1 - (embedding <=> %(vec)s::vector) AS score
FROM chunks
ORDER BY embedding <=> %(vec)s::vector
LIMIT %(k)s
Y la regla que hace que el chatbot sea un chatbot sobre mi blog y no un chatbot genérico vive en el system prompt:
You are a grounded assistant that answers ONLY using the provided context,
which comes from Alexis Alulema's blog posts.
- Reply in the SAME language as the user's question (English or Spanish).
- Base every statement strictly on the context. Do NOT use outside knowledge.
- If the context does not contain the answer, say —in the user's language— that
you can only answer questions about Alexis Alulema's blog posts.
- Cite the sources you use by their titles.
Aunque el verdadero grounding no lo hace el prompt: lo hace el umbral. Si ningún chunk supera la similitud mínima, la app ni siquiera llama al LLM y devuelve un mensaje fijo, escrito a mano, en el idioma detectado. (Más adelante cuento por qué esa decisión resultó ser incluso más importante de lo que yo creía.) Un modelo de 0.5B al que no le das contexto inventa; la mejor defensa contra la alucinación es no darle la oportunidad.
Ingesta: el trabajo sucio
Los posts se traen del sitemap público del sitio (no del repo privado del blog), se limpian, se trocean y se embeben. La limpieza fue, como siempre, la parte menos glamorosa y la más determinante:
- El sitio es Astro: el contenido real vive en
.post-content; el header y el footer son navegación. - Las ligaduras de iconos (Material Symbols) se colaban como palabras sueltas —
beenhereen medio de un párrafo — igual que las tablas de contenidos con sus anclas. - KaTeX duplicaba cada fórmula: MathML + anotación LaTeX + la versión visual. Tres copias del mismo teorema compitiendo en el mismo chunk.
- Mojibake clásico:
requestsadivinaba Latin-1 y me devolvía acentos rotos. Forzar UTF-8 y normalizar /zero-width lo cerró.
🎓 El detalle que más me gustó resolver: paraphrase-multilingual-MiniLM-L12-v2 trunca la
entrada a ~128 tokens, pero mis chunks son de 400. Si embebes el chunk directo, pierdes tres cuartas
partes del texto sin que nada te avise. La solución fue partir cada chunk en sub-ventanas de ≤128
tokens y hacer mean-pooling de sus vectores: el chunk que ve el LLM sigue siendo grande, y el
vector que lo representa realmente lo representa completo.
Los embeddings son multilingües, así que el retrieval es cross-lingual gratis: una pregunta en español recupera chunks en inglés y viceversa. La primera prueba end-to-end preguntó “What are activation functions?” y recuperó los dos posts correctos — el inglés y su versión en español.
Calibrar el umbral: donde casi me engaño solo
El umbral de similitud es el parámetro más importante del proyecto: decide cuándo el bot responde y cuándo dice “solo puedo hablar de los posts de Alexis”. Ponerlo a ojo es tentador y es un error, así que escribí un pequeño harness: una batería de preguntas etiquetadas dentro del corpus (EN, ES y cross-lingual) y fuera del corpus, que mide la distribución de scores de cada grupo y recomienda un corte.
La primera calibración fue un sueño: separación limpia, in_min = 0.438 vs out_max = 0.205, un
hueco de 0.233 y cero mal clasificados. Umbral en el medio: 0.32. Caso cerrado.
No estaba cerrado. Con el demo ya corriendo, se probaron nueve preguntas fuera de corpus y dos se
colaron: useState y useEffect de React generaron una respuesta real en vez de rechazar.
Kubernetes, Vue, Angular y Tailwind rechazaron bien en esa tanda. Al reproducirlo localmente contra
la misma imagen de la base de datos, el diagnóstico fue incómodo:
| Pregunta fuera de corpus | top-1 |
|---|---|
| Vue.js composition API | 0.492 |
React useState | 0.475 |
React useEffect | 0.458 |
| React hooks | 0.388 |
…y el piso real dentro del corpus — la pregunta sobre mi post de Levenshtein en JavaScript — está en 0.425. Es decir: por debajo de Vue. No existe ningún umbral que cierre la fuga sin dejar de responder sobre un post que sí escribí.
El error no estuvo en el umbral, estuvo en la batería: mis negativos eran temas obviamente ajenos (Francia, pizza, Taylor Swift). Los negativos que importan son los cercanos — los que rozan tu dominio sin estar en él. Un post sobre algoritmos en JavaScript y una pregunta sobre frameworks frontend viven a milímetros en el espacio de embeddings, y con chunks de 400 tokens esa distancia se acorta todavía más.
La decisión fue explícita, no automática: subir a 0.42 (aprieta el margen sin rechazar ningún tema real) y aceptar la fuga de React/Vue como limitación conocida y documentada. El harness, por accuracy pura, recomendaba 0.577 — lo que habría rechazado “How do transformers work internally?”, el tema más central del blog. Optimizar la métrica del harness a costa de tu contenido más importante es exactamente el tipo de decisión que hay que tomar a mano.
Performance en CPU: el prompt es tu presupuesto
Con el demo desplegado apareció el problema esperado: lentitud. Y una métrica que en CPU importa más que las demás: el TTFT (time to first token), es decir, el prefill del prompt antes de que salga la primera palabra. La plataforma tiene un techo de 2 vCPU / 4 GiB, así que no había lever de hardware: había que hacer el trabajo más chico.
Tres rondas, cada una medida por separado para poder atribuir el efecto:
| Ronda | Cambio | Resultado medido |
|---|---|---|
| Perf I | LLM 1.5B → 0.5B + max_output_tokens 512 → 256 | generación ~25-30 tok/s (~3×) |
| Perf II | TOP_K 5 → 3 | TTFT ~17.6 s → ~10.1 s |
| Perf III | CHUNK_TOKENS 600 → 400 (+ recalibrar umbral) | TTFT frío ~5.7 s |
La lección es de contabilidad, no de magia: en CPU el contexto recuperado es la parte grande y variable del prompt. Pasar de 5 chunks de 600 tokens a 3 chunks de 400 llevó el prompt de ~1800 a ~1200 tokens, y el TTFT cayó a un tercio del original. El streaming token a token hace el resto del trabajo — no reduce la latencia, pero cambia por completo cómo se percibe.
⚠️ Un detalle que casi me hace medir mal: repetir la misma pregunta daba ~1 s. Es el KV cache de Ollama, no una mejora. Las mediciones que valen son con preguntas nuevas y en frío.
Y una regla que salió de aquí: cambiar el tamaño del chunk obliga a recalibrar el umbral. Chunks más pequeños son más focalizados, la densidad de similitud cambia, y el corte que era correcto deja de serlo. Al pasar a 400 tokens el hueco entre “dentro” y “fuera” se estrechó de 0.233 a 0.069. Menos latencia, menos margen de grounding: ese fue el trueque, tomado con los números a la vista.
Dos bugs que enseñaron más que el happy path
”¿De qué temas habla este blog?” → rechazo
Una pregunta trivial, y el bot rechazaba. La causa era elegante en su obviedad: ningún chunk resume el corpus. Cada chunk es un fragmento de un post concreto, y una pregunta meta no se parece lo suficiente a ningún fragmento en particular. No era un umbral mal puesto — era una laguna del corpus.
La solución fue de ingesta, no de runtime: generar en build-time un documento sintético de resumen por idioma que enumera los temas, y dejarlo fluir por el pipeline normal (chunk → embed → dump). Se recupera como cualquier otro chunk, y la cita apunta al índice del blog.
La primera versión enumeraba títulos, y falló a medias: un título no siempre nombra la
tecnología. Entonces revisé el HTML del sitio y ahí estaba lo que necesitaba: cada post ya publica sus
tags (machine-learning, python, transformers, c#, cqrs, ddd…), curados a mano por mí
al publicar y que el limpiador nunca estaba scrapeando. Metadata real gana a cualquier heurística de
extracción de keywords que hubiera inventado. Hoy esa pregunta recupera el resumen con top-1 = 0.694,
holgadamente sobre el umbral, y es el primer chip sugerido en la UI: mostrarle al usuario el alcance
real del corpus desde el primer segundo es también la mejor mitigación contra las preguntas fuera de
tema.
”El chat no tiene memoria” → sí la tenía
El reporte fue: los follow-ups tipo “¿y en Python?” se sienten sin memoria. La hipótesis natural es que falta historial. Pero el historial estaba, completo, de punta a punta: el cliente lo mantiene y lo reenvía en cada turno, y el prompt lo incluye turno a turno.
El bug estaba un paso antes. El retrieval embebía solo la última pregunta. Un follow-up dependiente del contexto, por sí solo, no tiene suficiente señal para pasar el umbral → cero chunks → la app rechaza sin llamar al LLM, ignorando que el historial sí traía el contexto. La sensación de “no recuerda” no era del modelo olvidando: era del retrieval nunca dándole la oportunidad de recordar.
El arreglo fueron dos cambios independientes: una ventana de 5 turnos en el cliente (el historial crecía sin límite y engordaba el prompt que tanto había costado adelgazar) y un reintento contextualizado: si el retrieval con la pregunta sola sale vacío y hay historial, se reintenta una vez anteponiendo el último turno del usuario — solo para el embedding de la búsqueda, nunca en lo que se le manda al LLM. El camino normal no cambia, así que la calibración del umbral sigue siendo válida para él.
Me quedo con esta como la mejor lección del proyecto: en un sistema RAG, el síntoma casi nunca apunta a la capa culpable. “El modelo no recuerda” era un problema de retrieval. “El umbral está mal” era un problema del corpus. “Es lento” era un problema de tamaño de prompt.
La primera usuaria real (o: cómo mi hermana rompió el demo en cinco minutos)
Todo lo anterior lo probé yo, que sé exactamente qué construí y —sin darme cuenta— escribo las preguntas que el sistema espera. Entonces le pedí a mi hermana que lo probara. No es desarrolladora, no leyó nada sobre el proyecto y entró al chat como entra cualquiera: saludando.
Su sesión completa fue de dos mensajes. Encontró tres cosas.
“Hola” → respuesta en inglés. El detector de idioma era una heurística de palabras clave, y
hola no estaba en ninguna lista: sin tildes que delataran el español y sin ninguna palabra
conocida, el marcador quedaba 0-0 y caía al default, que era inglés. Todas mis pruebas habían sido
preguntas completas —¿Cómo funcionan los transformadores?— llenas de señal. Un saludo suelto, el
mensaje más común con el que un humano abre un chat, era justo el caso sin señal.
“Quien es Alexis?” → el stream se cortó a media respuesta. Alcanzó a escribir “Alexis Alulema es” y murió la conexión. Ella no volvió a intentar: cerró el chat. La causa estaba en el arranque: Ollama carga los pesos del modelo de forma perezosa, en la primera inferencia real, y mi health check solo verificaba que el servidor respondiera. Todas las mediciones de TTFT de las que tan orgulloso estaba se habían hecho con el modelo ya caliente. Su pregunta fue la primera inferencia de verdad en ese contenedor, pagó la carga completa, y el gateway cortó por timeout antes de terminar. La solución fue precargar el modelo durante el arranque, antes de reportar healthy: ahora el contenedor tarda unos segundos más en levantar, y ningún visitante paga esa factura.
Y de ahí salió el tercero, que es el que más me enseñó.
La tentación de arreglarlo con un prompt
Probando después de esos arreglos, me topé con el rechazo enlatado: pregunté “¿Qué temas conoces?”, no pegó con ningún chunk, y recibí la misma frase seca de siempre. Suena a robot. La idea obvia: en vez de la frase fija, llamar al LLM con un prompt distinto que le pida reconocer amablemente que no tiene esa información e invitar a preguntar sobre temas del blog. Más humano, más conversacional.
Lo implementé, levanté el stack completo y lo probé con preguntas genuinamente fuera de tema:
| Pregunta (sin ningún chunk recuperado) | Respuesta |
|---|---|
| ¿Cuál es la capital de Francia? | ”La capital de Francia es París.” |
| ¿Cuál es la mejor pizza del mundo? | Se inventó una receta con total seguridad |
| ¿Quién ganó el mundial 2022? | “…ganó Brasil” |
El prompt decía, literalmente, “NO uses conocimiento externo para responder la pregunta original”. El modelo lo ignoró las tres veces. Y en la tercera, además de romper la regla, mintió: ganó Argentina.
Ahí está la lección, y es la más importante que me llevo del proyecto: un modelo de 0.5B no respeta instrucciones negativas de forma confiable, así que una garantía dura no puede vivir en un prompt. “Grounded-only” es una promesa que le hago a quien usa el demo; si su cumplimiento depende de que el modelo tenga la disciplina de abstenerse, no es una garantía, es una esperanza. Reverti el cambio: cuando no hay contexto, el LLM sencillamente no se invoca — la respuesta es determinística, escrita por mí, y sí, más amable que la original (reconoce que no sabe y sugiere temas reales). No tiene variedad conversacional. Nunca inventa. Ese trueque no está ni cerca de ser difícil.
Y hay un detalle que me parece el resumen de toda esta sección: ninguno de los tres problemas lo podía encontrar mi suite de tests. Los 72 tests pasaban en verde mientras el bot saludaba en el idioma equivocado, se colgaba con el primer visitante e inventaba finales del Mundial. Hicieron falta una persona que no sabía nada del sistema y levantar el stack real para verlos.
Lo que deliberadamente no hice
- Sin fine-tuning. El modelo es fijo; lo único que se re-indexa es el corpus. Publicar un post nuevo no reentrena nada.
- Sin conversaciones persistidas en el servidor. El historial vive en el cliente, y por eso el demo tolera que lo maten a mitad de sesión.
- Sin re-indexar en runtime. El corpus es fijo por imagen. Se refresca al construir, no al desplegar: un workflow re-ingesta, regenera el dump y republica la imagen; el siguiente demo que se provisione ya usa el corpus fresco.
- Sin auth propia en la app: la hace el gateway.
Y la limitación honesta, ya mencionada: preguntas sobre frameworks frontend adyacentes (React, Vue) pueden colarse y generar una respuesta pobre en vez de un rechazo limpio. Está medido, documentado y aceptado — que es una situación muy distinta a “no lo sabíamos”.
Conclusión
Este proyecto no inventa nada: RAG es una arquitectura conocida y bien documentada. Lo interesante estuvo en el otro lado — en las restricciones. Un LLM de 0.5B, dos vCPUs, sin API de pago y con un ciclo de vida de veinte minutos convierten decisiones que normalmente son cómodas (¿top-k? ¿tamaño de chunk? ¿umbral?) en trueques medibles con consecuencias visibles. Y medir cada uno por separado, en vez de cambiar tres cosas a la vez, es lo que permitió atribuir cada mejora a su causa.
Hay un cierre que me hace sonreír: el pipeline de publicación de este sitio dispara la re-indexación del corpus. En cuanto esta entrada esté publicada, el chatbot va a poder responder preguntas sobre ella — incluida, supongo, la pregunta de por qué me tomó tres años volver a escribir.
El chatbot está disponible como demo en vivo, junto con el resto de experimentos, en alexisalulema.com/projects — se provisiona cuando lo pides, corre unos minutos y se destruye solo. Y el código es público, con su bitácora completa de decisiones, mediciones y errores: github.com/alulema/rag-blogposts.
Volví. Y esta vez traigo cosas construidas para contar.