Ir al contenido

Ponle un bot de Discord a tu IA privada para que tu familia sí la use

·5124 palabras·25 mins
Emiliano Fernández Cervantes
Autor
Emiliano Fernández Cervantes
Construyo cosas en la frontera entre el hardware y el software: arquitecturas en Verilog, instrumentación biomédica y un home lab que nunca deja de crecer.
IA privada en casa - Este artículo es parte de una serie.
Parte 2: Este artículo

Montaste un asistente de IA privado en tu propia computadora, le cargaste el manual de tu casa, y responde de maravilla. ¿Entonces por qué nadie en tu casa lo usa?

Si esto te suena familiar, el problema casi nunca es el modelo: es la puerta de entrada. Pedirle a alguien de tu familia que recuerde una URL, abra el navegador, inicie sesión y elija el modelo correcto de una lista es pedirle que cambie sus hábitos, y los hábitos casi nunca pierden. En lugar de esperar a que ellos vayan al asistente, pon el asistente donde ya están: en una app de chat que ya tienen abierta en el celular.

En mi casa esa app es Discord, y esta guía te lleva por el puente que construí para lograrlo. Al terminar vas a tener un bot pequeño de Discord que toma mensajes !ask de un servidor familiar, los reenvía a una instancia propia de Open WebUI respaldada por Ollama, y publica la respuesta de vuelta en el canal.

Además, el camino vale la pena por sí solo. En el trayecto vas a trabajar con integración de APIs REST, networking de Docker, presupuesto de memoria de GPU y generación aumentada por recuperación, un conjunto de habilidades muy transferible para un proyecto que puedes terminar en un fin de semana.

Un supuesto antes de empezar: esta guía arranca donde termina la IA privada. Esta es la parte dos de la serie, así que si todavía no tienes un asistente corriendo, la parte uno sobre cómo desplegar tu IA privada con Ollama construye esa mitad del stack, y todo lo que sigue aquí es la capa que finalmente la vuelve útil para todos los demás en la casa.

Un canal de Discord familiar donde alguien hace una pregunta con el comando !ask y el bot responde con información sacada del manual de la casa
Sin app nueva, sin login, sin menú desplegable que equivocarle. El modelo nunca fue la parte difícil de este proyecto, la puerta de entrada sí.

El stack: un puente deliberadamente aburrido
#

El principio de diseño detrás de todo esto es que el bot debe ser aburrido. No hace nada de IA: no procesa documentos, no administra prompts, y no le habla a Ollama. Solo lee mensajes de Discord y hace llamadas HTTP. Eso deja las partes interesantes (el system prompt, la base de conocimiento, la elección del modelo) en un solo lugar, donde las puedes cambiar sin tocar ni una línea de Python.

Servidor de Discord familiar              la interfaz que todos ya tienen
        │  !ask ¿Dónde está la llave de paso del agua?
Bot de Discord (Python, Docker)           un puente delgado, sin lógica de IA
        │  POST /api/chat/completions     Authorization: Bearer <key>
Open WebUI (Docker)                       system prompt + manual de la casa (KB)
        │  OLLAMA_BASE_URL
Ollama (servicio nativo, GPU)             el runtime del modelo

Todo excepto Discord corre en WSL2 en mi computadora de escritorio, que es donde está la GPU: una NVIDIA GeForce RTX 3070 Ti con 8 GB de VRAM, la misma tarjeta de la PC que armé yo mismo. Acuérdate de esos 8 GB, porque más adelante son los que deciden en silencio qué modelo puedes correr. Open WebUI y el bot están deliberadamente juntos en la misma instancia de WSL con network_mode: host, así se alcanzan entre ellos y alcanzan a Ollama por localhost, sin networking entre máquinas que debuggear. Mi servidor de casa era el candidato obvio para hospedar el bot, aunque tiene alrededor de 1.8 GB de RAM y ya está ocupado, así que mantener las tres piezas juntas en la máquina con GPU resultó más simple y más rápido.

Hay una propiedad que vale la pena señalar, porque define toda la parte de seguridad: el bot es solo de salida. Él marca hacia Discord y hacia Open WebUI, y nada en internet se conecta jamás hacia adentro de mi casa para alcanzarlo. Ese fue el factor decisivo cuando comparé Discord contra WhatsApp, cuya Cloud API oficial espera un webhook entrante, mientras que las librerías no oficiales cargan tanto riesgo de baneo como una segunda dependencia que mantener.


Paso 1: Crear el bot de Discord
#

Entra al Discord Developer Portal, crea una nueva aplicación y abre la pestaña Bot.

Aquí importan tres cosas:

  1. Copia el token y guárdalo en un lugar seguro. Es la contraseña de tu bot, y va en un archivo .env que nunca se sube al repositorio.
  2. Activa el Message Content Intent en Privileged Gateway Intents. Sin esto tu bot se conecta sin problemas, se queda en el canal, e ignora silenciosamente cada mensaje, porque Discord simplemente no le manda el texto. Es la forma más fácil de perder una tarde en este proyecto, así que hazlo desde ahorita.
  3. Invita al bot a tu servidor con Send Messages, Read Message History y Embed Links. No necesita más, y darle los menos permisos posibles a un bot que vive en tu casa siempre es el instinto correcto.

Paso 2: El conocimiento va en Open WebUI, no en el bot
#

Esta es la decisión que mantiene el proyecto pequeño, así que vale la pena tomarla antes de escribir código.

Dentro de Open WebUI, ve a Workspace → Models y crea un modelo personalizado. El mío se llama Family1, y carga dos cosas: un system prompt en español que le dice al modelo que es un asistente del hogar, y una colección de conocimiento con el manual de la casa. Ese manual es simplemente un documento en Markdown que describe la configuración del Wi-Fi, los dispositivos del hogar inteligente, el centro de carga, los electrodomésticos, y todos esos pedacitos de conocimiento que normalmente viven en la cabeza de una sola persona.

Open WebUI se encarga de la recuperación por su cuenta. Cuando llega una pregunta, extrae los fragmentos relevantes del manual y los entrega al modelo como contexto, y eso es lo que hace que las respuestas sean específicas de tu casa y no genéricamente plausibles. La consecuencia importante es arquitectónica: el prompt y el conocimiento viven en el volumen de datos de Open WebUI, no en Ollama ni en el bot. Eso significa que puedes reescribir la personalidad del asistente o subir una versión nueva del manual sin reconstruir ni reiniciar nada.

Ese mismo hecho es también una advertencia. Todo lo que hace que el asistente sea tuyo está en un solo volumen de Docker, así que respáldalo antes de cualquier cambio grande:

docker run --rm -v open-webui:/data -v $PWD:/backup alpine \
  tar czf /backup/owui-data.tgz -C /data .

Por último, genera la credencial que va a usar el bot: Settings → Account → API Keys → Generate new key. Cópiala en ese momento, porque después ya no se puede ver.


Paso 3: El puente entre Discord y la API de Open WebUI
#

Ahora el código. Todo el bot es un solo archivo de Python con dos comandos, y eso no es casualidad. Cada función que me dieron ganas de agregar resultó pertenecer más arriba, en Open WebUI.

Las dependencias son mínimas:

discord.py>=2.3,<3
requests>=2.31,<3
python-dotenv>=1.0,<2

El corazón es una sola función que manda la pregunta y saca la respuesta del JSON. El código va tal cual está en el repositorio, con sus comentarios en inglés incluidos, para que puedas copiarlo y compararlo línea por línea sin sorpresas; la explicación corre por cuenta del texto de aquí abajo:

# OpenWebUI uses the OpenAI-compatible chat completions endpoint.
# Previously this was /api/chat (wrong) — the correct path is /api/chat/completions.
ASK_ENDPOINT = f"{OPENWEBUI_URL}/api/chat/completions"


def ask_openwebui(question: str) -> str:
    """Send a question to OpenWebUI and return the model answer."""

    headers = {"Content-Type": "application/json"}
    if OPENWEBUI_API_KEY:
        headers["Authorization"] = f"Bearer {OPENWEBUI_API_KEY}"

    # No system message here on purpose: the OpenWebUI model "Family1" already
    # carries its own (Spanish) system prompt + the "Manual Casa" knowledge base.
    # Sending a second system message here competes with / overrides that prompt.
    payload = {
        "model": OPENWEBUI_MODEL,
        "messages": [
            {"role": "user", "content": question},
        ],
        "stream": False,
    }

    log.info("POST %s  model=%s", ASK_ENDPOINT, OPENWEBUI_MODEL)
    response = requests.post(ASK_ENDPOINT, json=payload, headers=headers, timeout=180)

    if not response.ok:
        log.error(
            "OpenWebUI returned HTTP %d: %s",
            response.status_code,
            response.text[:400],
        )
    response.raise_for_status()

    data = response.json()

    # Try a few common response shapes so the script is easier to adapt.
    # OpenWebUI's /api/chat/completions returns the standard OpenAI shape:
    # {"choices": [{"message": {"content": "..."}}]}
    if isinstance(data, dict):
        if "choices" in data and data["choices"]:
            return data["choices"][0]["message"]["content"].strip()
        if "message" in data and isinstance(data["message"], dict):
            content = data["message"].get("content")
            if content:
                return str(content).strip()
        if "content" in data and isinstance(data["content"], str):
            return data["content"].strip()

    return str(data)

Varios detalles ahí son críticos, y a cada uno llegué después de equivocarme.

Usa la ruta compatible con OpenAI. Open WebUI expone /api/chat/completions, no /api/chat. Mi primera versión usaba la segunda y solo obtuve errores que parecían un problema de autenticación.

OPENWEBUI_URL debe ser la URL base pelona. El script agrega la ruta él mismo, así que poner una ruta en la variable la duplica y produce un 404 muy confuso.

No mandes un system message. Se siente natural definir la personalidad del asistente en el código, pero al hacerlo compites con el prompt que ya está pegado al modelo personalizado, y el resultado es un asistente con dos juegos de instrucciones que se contradicen.

Parsea a la defensiva. Open WebUI devuelve el formato estándar de OpenAI, pero verificar un par de formatos alternativos cuesta un puñado de líneas y deja el script listo para apuntar a otro backend. Loggear el status code y los primeros cientos de caracteres de una respuesta fallida cuesta más o menos lo mismo, y es la diferencia entre debuggear con evidencia y debuggear a ciegas.

El lado de Discord es igual de pequeño. !ask corre dentro de un indicador de “escribiendo”, para que la familia vea que el bot está trabajando en lugar de asumir que está roto, cada camino de error termina en una frase que un humano puede leer, y la respuesta se parte en pedazos antes de enviarse porque Discord rechaza mensajes de más de 2000 caracteres:

@bot.command(name="ask")
async def ask(ctx: commands.Context, *, question: str) -> None:
    """Ask the family assistant a question.

    Usage:
        !ask How do I turn on movie mode?
    """

    async with ctx.typing():
        try:
            answer = ask_openwebui(question)
        except requests.RequestException as exc:
            status = getattr(getattr(exc, "response", None), "status_code", None)
            detail = f" (HTTP {status})" if status else ""
            await ctx.send(f"Sorry, I could not reach OpenWebUI{detail}: {exc}")
            return
        except Exception as exc:  # noqa: BLE001 - show a friendly error message
            await ctx.send(f"Something went wrong: {exc}")
            return

    if not answer:
        await ctx.send("I did not get an answer back.")
        return

    # Discord message limit is 2000 characters.
    if len(answer) <= 2000:
        await ctx.send(answer)
        return

    # If the answer is long, split it into chunks.
    chunk_size = 1900
    for start in range(0, len(answer), chunk_size):
        await ctx.send(answer[start : start + chunk_size])

Los mensajes de error salen en inglés porque así están en el repositorio; si tu familia prefiere leerlos en español, ese es el primer cambio obvio que puedes hacerle al script.

Un comando !ping que contesta pong lo completa. Suena trivial, pero responde la pregunta más común de la casa ("¿está caído o nada más está lento?") sin que nadie tenga que leer un log.


Paso 4: Empaquetar en un container y correrlo
#

La imagen es prácticamente lo más simple que puede ser una imagen de Python, con la capa de dependencias cacheada aparte del código para que los cambios se reconstruyan en segundos:

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY family_discord_openwebui_bot.py .
CMD ["python3", "family_discord_openwebui_bot.py"]

Y el archivo de Compose:

services:
  bot:
    build: .
    container_name: family-assistant-bot
    restart: unless-stopped
    env_file: .env
    network_mode: host

network_mode: host es lo que permite al container alcanzar Open WebUI en un simple localhost:8080, ya que ambos viven en la misma instancia de WSL. restart: unless-stopped significa que el bot regresa solo después de un reinicio, lo cual importa cuando se supone que la casa puede depender de él.

Tu .env guarda cuatro valores:

DISCORD_TOKEN=tu_token_de_discord
OPENWEBUI_URL=http://localhost:8080
OPENWEBUI_MODEL=ollama-family1:latest
OPENWEBUI_API_KEY=sk-...

OPENWEBUI_MODEL merece una segunda mirada, porque es la trampa más sutil de todo el proyecto. Tiene que ser el id del modelo personalizado que creaste en el Paso 2, el que tiene el manual de la casa adjunto. Si lo apuntas al motor pelón, todo parece funcionar: el bot se conecta, las preguntas reciben respuesta, las respuestas suenan fluidas. También están completamente desconectadas de tu casa, porque te brincaste el system prompt y la recuperación de documentos. Confirma el id exacto en Workspace → Models en lugar de adivinarlo, y no lo copies del .env.example de mi repositorio, que ahí todavía trae el id del motor y es exactamente el error del que te está advirtiendo este párrafo.

Luego lo levantas:

docker compose up -d
docker compose logs -f bot

Los logs deben mostrar Logged in as ... y Bot is ready. En Discord, !ping debe devolver pong, y !ask debe devolver algo que solo tu propio manual pudo haberle dicho.


Las partes difíciles: cuatro obstáculos que conviene conocer
#

El puente me tomó una tarde. Todo lo que está debajo es donde estuvo la ingeniería de verdad, y las cuatro lecciones de abajo sirven mucho más allá de este proyecto, así que es la sección que yo leería primero si estuviera en tu lugar.

La GPU que no se estaba usando
#

Durante un tiempo el asistente respondía bien pero dolorosamente lento, y los logs explicaban por qué: Ollama seguía cayendo a library=cpu, reportando failure during GPU discovery ... failed to finish discovery before timeout después de unos treinta segundos, tanto para CUDA 12 como para CUDA 13. Al mismo tiempo, nvidia-smi dentro de WSL listaba la tarjeta sin ningún problema. CUDA se veía sano y la GPU se veía presente, y aun así la inferencia corría en CPU.

Rastrear el proceso reveló el mecanismo real. Ollama descubre GPUs lanzando un subproceso runner que escucha en 127.0.0.1 en un puerto aleatorio, y luego se conecta a él por loopback para preguntarle qué hardware existe. Mi WSL estaba configurado con networkingMode=mirrored, y en modo mirrored esas conexiones locales de proceso a proceso se enrutan por el stack de red de Windows y se quedan colgadas. El connect() del proceso padre se quedaba sin resolver hasta que expiraba el timeout, el runner era terminado, el descubrimiento “fallaba”, y Ollama concluía que no había GPU.

Dos experimentos rápidos lo confirmaron. Un script de cinco líneas en Python que escuchaba y se conectaba en 127.0.0.1 se colgaba exactamente igual, y una multiplicación de matrices en PyTorch terminó en 0.4 segundos en la GPU, lo que ubicaba la falla en el loopback y no en CUDA ni en Ollama.

La solución fue una línea en C:\Users\fdeze\.wslconfig:

[wsl2]
networkingMode=NAT
localhostForwarding=true

Después de un wsl --shutdown, Ollama cargó el modelo al 100% en GPU. De pasada, NAT también me devolvió la VPN de la universidad, que el modo mirrored había roto sin avisar. La lección a la que sigo regresando: cuando el síntoma apunta a la capa exótica (drivers, CUDA, la GPU), verifica primero la capa aburrida. Era networking.

Elegir un modelo que de verdad quepa
#

Mi primer motor fue qwen3.5-tuned, un Qwen3.5 de 9.7B cuantizado a Q4_K_M con un contexto de 8192 tokens. En papel cabía, y ollama ps estaba muy de acuerdo: PROCESSOR decía 100% GPU. La aritmética es la que cuenta la historia real. Ese motor ocupa 6.6 GB, el escritorio de Windows ya trae apartados alrededor de 1.3 GB de la tarjeta antes de que Ollama pida algo, y 6.6 más 1.3 aterrizan en unos 7.9 GB de los 8.2 GB de la RTX 3070 Ti. Eso son unos 250 MB de margen en una tarjeta que está al 97 por ciento.

Vivir al 97 por ciento no es un lugar estable. En reposo el modelo era perfectamente reproducible, generando alrededor de 70 tokens por segundo corrida tras corrida. Pero mientras hacía las mediciones, ese mismo modelo con ese mismo prompt se desplomaba de manera intermitente a unos 20 tokens por segundo, con el procesamiento del prompt cayendo de unos 800 tokens por segundo a unos 133, justo cuando el escritorio de Windows pedía VRAM y esos últimos 250 MB simplemente ya no estaban. Ese es el derrame: el KV cache y los buffers de cómputo se van a la memoria del sistema y el trabajo deja de ser trabajo de GPU sin avisarle a nadie. Una lentitud ocasional e impredecible es peor para un servicio familiar que una lentitud permanente, porque nadie puede saber si está descompuesto o nada más está teniendo un mal minuto. Que fuera un modelo de razonamiento lo empeoraba todavía más, porque la parte que corría a velocidad de CPU era justamente la larga traza de razonamiento.

La respuesta no era una tarjeta más grande, era dimensionar bien. Construí un motor más chico a partir de una base de 4B con contexto de 4096 tokens:

FROM qwen3.5:4b
PARAMETER num_ctx 4096
PARAMETER num_gpu 99
PARAMETER temperature 0.7
PARAMETER top_k 20
PARAMETER top_p 0.9
PARAMETER presence_penalty 1.5
PARAMETER repeat_penalty 1.1
ollama create qwen3.5-4b-tuned -f qwen-family-4b.Modelfile
ollama ps   # la prueba de aceptación: PROCESSOR debe decir 100% GPU

El motor de 4B ocupa 5.5 GB, lo que deja 2.7 GB de la tarjeta sin reclamar por el modelo y todavía cerca de 1.4 GB realmente libres una vez que el escritorio toma su parte. Ese margen es todo el punto, y los números lo siguieron: el 4B sostiene alrededor de 103 tokens por segundo contra los 70 del 9.7B en su mejor momento, y los sostiene de manera consistente en lugar de a ratos. Dimensionar bien no me costó velocidad, me compró velocidad.

Así que la verdadera prueba de aceptación no es que PROCESSOR diga 100% GPU, porque el motor grandote también pasaba esa prueba. Es que diga 100% GPU con todavía cerca de un gigabyte de VRAM libre, y ese gigabyte libre es la diferencia entre un asistente en el que la familia confía y uno que abandonan. Aunque bajar de 9.7B a 4B suena a retroceso, el costo en calidad resultó pequeño, precisamente porque las respuestas se apoyan en el manual de la casa y no en el conocimiento memorizado del modelo. La recuperación de documentos me dejó gastar mi VRAM en velocidad en lugar de en datos de cultura general.

Eso sí, ese argumento carga un supuesto que yo no había probado: solo se sostiene mientras la recuperación de verdad encuentre el pasaje correcto. Regreso a eso más abajo, porque cuando por fin lo medí resultó ser el eslabón más débil de todo el stack.

Apagar el razonamiento
#

El último obstáculo fue el más raro. Mi motor razona por defecto, emitiendo cientos de tokens internos hasta para un saludo, y combinado con un system prompt grande y los fragmentos recuperados del manual dentro de un presupuesto de 4096 tokens, esa traza de razonamiento se comía la respuesta. No quise quedarme con la impresión, así que lo reproduje: la misma pregunta, la misma semilla, el contexto llenado a propósito hasta los 4096 tokens completos del modelo para igualar los 1,547 a 2,284 tokens que carga una pregunta real después de la recuperación, una corrida con el razonamiento apagado y dos con él encendido.

thinktiempo realtokens de salidarazonamientorespuesta devuelta
false3.7 s114ninguno420 caracteres, limpia
true92.9 s8,19029,254 caracteresvacía, 0 caracteres
true51.0 s4,56116,993 caracteres464 caracteres

Esa tabla es la falla completa en un solo lugar. Con el razonamiento encendido, la traza consumía el presupuesto de salida antes de que la respuesta siquiera empezara: la mejor de las dos corridas todavía tardó 51 segundos en entregar 464 caracteres, y la otra se gastó 92.9 segundos emitiendo 29,254 caracteres de razonamiento para al final no devolver nada. Un mensaje en blanco después de minuto y medio no es un asistente lento, es uno descompuesto, y cualquiera en la casa concluiría justamente eso.

Vale la pena señalar un matiz, porque explica por qué esto es específicamente un problema de RAG: el razonamiento solo es catastrófico cuando el contexto está lleno. Con un prompt corto y sin restricciones, ese mismo motor nada más se va de unos 10 a 14 segundos a unos 27 a 30. Es la combinación de un system prompt grande, los fragmentos recuperados y un techo de 4096 tokens la que convierte una lentitud en una respuesta vacía. Por eso apagarlo es obligatorio en una ruta con RAG y no una optimización opcional.

La solución que todo mundo sugiere, poner /no_think en el prompt, no hizo absolutamente nada en este build: medí 479 tokens de razonamiento con el flag puesto. Lo que sí funcionó fue poner think: false como parámetro del modelo personalizado en Open WebUI, en Workspace → Models → Params. Tiene que vivir en la definición del modelo, porque el endpoint de chat completions de Open WebUI no reenvía un campo think que venga a nivel de request desde el bot.

La pestaña Params del modelo personalizado Family1 en Open WebUI, con la opción think puesta en false
Open WebUI, Workspace → Models → Family1 → Params. Es la única pantalla donde el ajuste se queda guardado, así que vale la pena ubicarla antes de ponerte a editar cualquier otra cosa.

Con eso resuelto, las respuestas llegan en unos cuatro segundos. Regresé a medirlo en serio en lugar de quedarme con la impresión: nueve preguntas enviadas de punta a punta a través de Open WebUI contra el modelo Family1 con el manual de la casa adjunto, cada una cargando entre 1,547 y 2,284 tokens de prompt después de la recuperación y produciendo entre 75 y 601 tokens de respuesta. La más rápida regresó en 2.3 segundos, la típica entre tres y seis, y la más lenta, que fue la primera de la corrida, en 8.4 segundos. Para una pregunta doméstica hecha desde el celular, eso es indistinguible de instantáneo.

Los arranques en frío eran la parte que daba miedo. Cuando el modelo todavía tenía que cargarse y hacer su primera recuperación, la primera pregunta del día tardaba de 100 a 134 segundos, que es exactamente la razón por la que el timeout HTTP del bot es de 180 segundos y no los 90 con los que empecé: se me caían por timeout peticiones que sí iban a funcionar. Esos números ya no se reproducen. Descargué el motor de Ollama y reinicié el container de open-webui para limpiar sus caches, luego cronometré cuatro preguntas seguidas, y la genuinamente fría regresó en 9.8 segundos, seguida de 6.2, 6.2 y 5.4 en caliente.

Aun así dejé el timeout generoso, porque un timeout al que nunca llegas no cuesta nada, mientras que uno un poquito corto te cuesta justo la petición que más querías que funcionara. Todas las mediciones de este post, junto con los scripts de benchmark que las producen, están en BENCHMARKS.md en el repositorio del bot, el que está enlazado al final, para que puedas volver a correrlas en tu propio hardware en lugar de creerme nada más porque sí.

Sobrevivir un reinicio
#

NAT le dio a WSL una IP privada que cambia en cada arranque, lo cual rompería el acceso a Open WebUI desde la red local. Una regla netsh portproxy en Windows reenvía los puertos 8080 y 11434 del host hacia la dirección actual de WSL, y como esa dirección se mueve, un pequeño script de PowerShell refresca las reglas y abre las entradas correspondientes del firewall, ejecutado por una tarea programada al iniciar sesión. La IP del host de Windows nunca cambia, así que la configuración de mi reverse proxy y mi dominio público nunca se tienen que tocar.


El eslabón más débil: medir si la recuperación de verdad recupera
#

Todo lo anterior descansa en una afirmación que nunca había puesto a prueba: que apoyar las respuestas en el manual de la casa es lo que le permite a un modelo de 4B hacer este trabajo. Si eso es cierto, la recuperación es el componente más importante del stack, y eso significa que merece su propia medición. Por fin se la hice, y el resultado honesto es que también es la parte más débil de lo que construí.

El truco está en calificar la recuperación con el modelo fuera de la jugada. Para cada una de dieciséis preguntas le pedí a Open WebUI los primeros k fragmentos y revisé si la respuesta correcta aparecía tal cual en lo que regresaba. Sin generación, sin juzgar la redacción, nada más un sí o un no sobre si el dato llegó siquiera a la ventana de contexto. Esa separación importa, porque una respuesta equivocada en un sistema RAG tiene dos causas completamente distintas, y no puedes arreglar la que no identificaste.

manualk=3k=5k=8
producción11/1612/1612/16
sin las imágenes embebidas12/1612/1612/16

En producción corro con TOP_K = 3, así que el número que describe mi casa es 11 de 16. Como una tercera parte de las veces, la respuesta nunca le llega al modelo. Cuando eso pasa el asistente no está equivocado, está desinformado, y desde afuera esas dos fallas se ven idénticas.

Lo que me sorprendió fue lo poco que movieron las perillas obvias. Subir la k compró una pregunta cuando mucho, y limpiar el manual compró otra cuando mucho. La razón es una división sorprendentemente limpia en los rankings: cada pregunta que el retriever acierta la acierta dentro de los primeros cuatro fragmentos, y cada pregunta que falla se va al lugar 10 o peor, en 15, 17, 19 y 24 con el manual de producción. No hay nada intermedio. Ninguna k realista rescata esas fallas sin arrastrar primero diez fragmentos de ruido a un presupuesto de 4096 tokens, que es la misma presión de contexto que hacía tan destructiva la traza de razonamiento más arriba.

Las fallas además tienen una forma, y eso es lo que señala la solución. Las cuatro persistentes son nombres propios que viven dentro de tablas de markdown: búsquedas de redes Wi-Fi, un número de modelo de hardware, ese tipo de pregunta. Es justamente la consulta que la búsqueda por palabra clave maneja bien y que los embeddings vectoriales densos manejan mal, porque una coincidencia exacta de token casi no suma puntos en un espacio construido para representar significado. Entonces la palanca no es traer más fragmentos, es ordenarlos mejor: un reranker de tipo cross-encoder sobre una lista de candidatos más amplia. Prender la búsqueda híbrida por sí sola no hace nada en mi instancia, porque sin un modelo de reranking configurado los candidatos combinados terminan recalificados por la misma métrica de similitud que ya los había ordenado.

Hay un hallazgo más que vale la pena llevarte a tu propia base de conocimiento, porque es gratis. Mi manual de producción pesa 836 KB, de los cuales solo unos 17 KB son texto. Todo lo demás son capturas de pantalla embebidas en base64, y por eso 698 de sus 744 fragmentos son ruido de imágenes en lugar de prosa. Quitarlas mejoró los rankings de manera general incluso donde el veredicto final no cambió. Una base de conocimiento no es una carpeta donde avientas documentos, es un documento que mantienes.

Así que la misma reflexión de siempre aplica también aquí: la recuperación es lo que le permitió a un modelo chico hacer el trabajo de uno grande, y la recuperación es también donde queda más margen de mejora. Vale la pena saberlo antes de suponer que adjuntar un documento es el final del trabajo. Los números están en la sección 6 de ese mismo BENCHMARKS.md, y el script que califica es bench/bench_rag.py, así que puedes apuntarlo a tu propia base de conocimiento y averiguar qué alcanza a ver tu asistente.


Lo que ganas con este setup
#

El resultado del día a día es poco glamoroso, y justo por eso funciona. Alguien escribe !ask en un canal de Discord que ya tenía abierto, y entre tres y seis segundos después sabe dónde está la llave de paso del agua, cómo regresar el proyector a la entrada correcta, o cuál es la contraseña del Wi-Fi. En mi casa nadie tuvo que aprender una herramienta nueva, y nadie tuvo que esperar a que yo contestara, que es lo más cercano al éxito que puede tener un proyecto casero.

El resultado de ingeniería es un stack con costuras limpias. El bot es sin estado a propósito: cada !ask manda solo esa pregunta, así que los seguimientos tipo “¿y cómo la cambio?” no funcionan. Eso es un intercambio deliberado, no un descuido. No guardar estado deja el contexto completo de 4096 tokens disponible para el system prompt y los fragmentos del manual, y un FAQ del hogar está hecho abrumadoramente de preguntas independientes. Agregaré historial por canal el día que alguien de verdad lo pida, y no antes.

Además, las habilidades que te llevas de aquí sirven en todas partes: integración de APIs REST y parseo defensivo de respuestas, networking de Docker y ciclo de vida de containers, presupuesto de memoria de GPU, y ese tipo de debugging metódico que separa un síntoma de una causa. Cada obstáculo del camino (el intent que olvidé activar, el endpoint equivocado, el loopback colgado, el modelo que no cabía) se convirtió en un pedazo de entendimiento que todavía me llevo conmigo. Cada tropiezo es simplemente otra oportunidad de aprender de los errores pasados y seguir mejorando, y en un home lab esas lecciones se acumulan más rápido que casi en cualquier otro lado.


¿Qué sigue?
#

Algunos caminos que me parecen valiosos de aquí en adelante:

  • Un reranker delante de la recuperación. Las fallas que medí son fallas de orden, no de presupuesto, así que el cambio que les corresponde es un rerank con cross-encoder sobre una lista de candidatos más amplia, no una k más grande. Es el punto de esta lista que espero que mueva más la aguja del asistente.
  • Seguimiento conversacional. Un historial corto por canal en el arreglo de messages permitiría una ida y vuelta natural, a costa del presupuesto de contexto. Vale la pena solo junto con una ventana de contexto más grande.
  • Slash commands. Los comandos nativos / de Discord con autocompletado serían más amigables que un prefijo ! para los miembros de la familia que nunca aprendieron la convención del prefijo.
  • Permiso de escritura, con cuidado. Ahora el asistente solo responde. Dejarlo actuar sobre el hogar inteligente sería el siguiente paso natural, y es el paso que más necesita barreras de seguridad: una lista explícita de acciones permitidas, y confirmación antes de cualquier cosa con consecuencias físicas.
  • Respaldos que de verdad corran. El system prompt y el manual de la casa viven en un solo volumen de Docker. Un snapshot programado de ese volumen es el punto menos glamoroso y más valioso de esta lista.

El bot completo está en GitHub, en EmilianFC20/family-assistant. Si construyes tu propia versión, me encantaría saber qué preguntas termina haciéndole tu casa con más frecuencia.

IA privada en casa - Este artículo es parte de una serie.
Parte 2: Este artículo