Juan I.
EN
Volver al blog
Desarrollo

RAG en producción por $12/mes: un chatbot de WhatsApp con ChromaDB y GPT-4o mini

Cómo construí un chatbot con Retrieval-Augmented Generation para una empresa real, con fallback multi-proveedor, personalidades intercambiables y soporte multimedia. Sin infraestructura compleja.

Juan I. Arnaboldi 8 min de lectura

$12 por mes

Eso cuesta mantener un chatbot de WhatsApp que responde preguntas sobre productos, precios y procesos de negocio de una empresa de cocina premium. Atiende en lenguaje natural, entiende imágenes y audios, y sabe cuándo derivar a un humano.

Está en producción. 184KB de base de conocimiento, 10 documentos entre catálogos, listas de precios y guías de negocio.

El stack: FastAPI, ChromaDB, GPT-4o mini, WhatsApp Business API. Sin Kubernetes. Sin Redis. Sin colas de mensajes. Un proceso Python y una base de datos vectorial de 6.7MB.

La arquitectura en una servilleta

WhatsApp/Telegram → Webhook → FastAPI

                         Pregunta del usuario

                    Embedding (all-MiniLM-L6-v2)

                     ChromaDB → Top 3 chunks

                   Prompt = Personalidad + Contexto RAG + Pregunta

                    GPT-4o mini (o fallback)

                         Respuesta + Confianza

                   WhatsApp/Telegram → Usuario

Cada mensaje recorre este camino en 2-5 segundos. El cuello de botella es la llamada al LLM, no la búsqueda vectorial.

El pipeline RAG

RAG suena complejo hasta que lo desarmás. Son tres pasos: chunking, embeddings, búsqueda.

Chunking: partir documentos en pedazos útiles

Los documentos de la base de conocimiento son Markdown: catálogos de productos, listas de precios con financiación, guías de negocio, términos y condiciones. Algunos tienen 36KB.

Un LLM no puede procesar 36KB de contexto en cada pregunta (bueno, puede, pero sería caro y lento). Entonces partimos cada documento en chunks de 1000 caracteres con 200 de overlap:

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
    separators=["\n\n", "\n", ". ", " ", ""]
)

El overlap de 200 es clave. Sin él, una oración que cae justo en el corte pierde contexto. Con él, los bordes se solapan y la búsqueda semántica encuentra matches incluso cuando la información relevante cruza dos chunks.

Los separadores están ordenados por prioridad: primero intenta cortar en párrafos, después en líneas, después en oraciones. Nunca corta una palabra por la mitad salvo que no quede otra.

Embeddings: convertir texto en vectores

Cada chunk se convierte en un vector de 384 dimensiones usando all-MiniLM-L6-v2. Es un modelo de HuggingFace que corre local — no hay llamada a API, no hay costo por embedding.

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("all-MiniLM-L6-v2")
embedding = model.encode(["¿Cuánto cuesta la olla FLIP?"])
# → vector de 384 floats

El modelo pesa ~90MB y genera embeddings en 200-500ms. Para una base de conocimiento de 10 documentos, el proceso completo de indexación tarda segundos.

Búsqueda: ChromaDB como cerebro

ChromaDB guarda los vectores en SQLite + índices HNSW. Cuando llega una pregunta, la convierte en vector y busca los chunks más cercanos:

def search(self, query: str, n_results: int = 5):
    query_embedding = self.model.encode([query])[0].tolist()
    results = self.collection.query(
        query_embeddings=[query_embedding],
        n_results=n_results,
        include=["documents", "metadatas", "distances"]
    )
    # ChromaDB devuelve distancias, las convertimos a similitud
    for distance in results["distances"][0]:
        similarity = 1 - distance  # 0.0 = nada que ver, 1.0 = idéntico
    return results

La query “¿cuánto sale la olla FLIP?” devuelve chunks del catálogo de precios con la ficha del producto, los planes de financiación y las consideraciones de garantía. Todo esto se inyecta como contexto en el prompt del LLM.

6.7MB. Toda la base vectorial. Cabe en un pendrive de los que te regalan en congresos.

La integración con WhatsApp

WhatsApp Business API funciona con webhooks. Meta te manda un POST cada vez que alguien escribe. Vos respondés llamando a su Graph API.

Verificación del webhook

@app.get("/webhook/whatsapp")
async def verify_whatsapp(request: Request):
    mode = request.query_params.get("hub.mode")
    token = request.query_params.get("hub.verify_token")
    challenge = request.query_params.get("hub.challenge")

    if mode == "subscribe" and token == VERIFY_TOKEN:
        return int(challenge)
    raise HTTPException(403, "Token inválido")

Meta hace un GET con un challenge. Si respondés con el número correcto, activa el webhook. Después todo llega por POST.

Procesamiento de mensajes

Cada mensaje de WhatsApp viene envuelto en varias capas de JSON. El texto está en entry[0].changes[0].value.messages[0].text.body. Las imágenes traen un media_id que hay que resolver con otra llamada a la API:

# Descargar media de WhatsApp
media_url = requests.get(
    f"https://graph.facebook.com/v17.0/{media_id}",
    headers={"Authorization": f"Bearer {access_token}"}
).json()["url"]

media_content = requests.get(
    media_url,
    headers={"Authorization": f"Bearer {access_token}"}
).content

Dos requests para un archivo. El primero te da la URL temporal, el segundo descarga el contenido. Es redundante, pero es la API de Meta.

Soporte multimedia

El bot recibe texto, imágenes, audio y documentos. GPT-4o mini ya tiene visión nativa, así que las imágenes van directo al modelo en base64:

if media_type == "image":
    user_message = [{
        "type": "image_url",
        "image_url": {
            "url": f"data:image/jpeg;base64,{media_data}"
        }
    }]

Para audio hay un modelo especializado (gpt-4o-mini-audio-preview) que procesa WAV directamente sin transcripción previa.

El sistema de fallback

Un chatbot en producción no puede depender de un solo proveedor. Si OpenAI se cae a las 3 de la mañana, el bot tiene que seguir respondiendo.

La solución: una cadena de fallback con tres niveles.

1. OpenAI GPT-4o mini          → $0.15/1M input, $0.60/1M output
2. OpenRouter (mismo modelo)    → pricing similar, distinta infra
3. DeepSeek v3 (tier gratuito) → $0

Cuando el provider primario falla, el sistema pasa al siguiente automáticamente:

try:
    response = call_openai(prompt, context)
except Exception:
    try:
        response = call_openrouter(prompt, context)
        response["fallback"] = True
        response["confidence"] *= 0.8  # penalizar confianza
    except Exception:
        response = call_openrouter_free(prompt, context)
        response["free_tier"] = True
        response["confidence"] *= 0.7

La penalización de confianza es importante. Si la respuesta viene del tier gratuito, el sistema es más agresivo para sugerir “hablá con un humano” — GPT-4o mini y un modelo free tienen diferencias reales de calidad.

Personalidades del bot

El mismo bot puede ser tres personas distintas según el contexto. Cada personalidad es un archivo .env con variables que definen tono, instrucciones y estilo:

Lucía Casual — Para clientes jóvenes. Tono relajado, muchos emojis, respuestas cortas.

Lucía Formal — Para clientes corporativos. Cero emojis, vocabulario técnico, foco en ROI.

Lucía Vendedora — Para conversión activa. Urgencia, prueba social, manejo de objeciones.

Cambiar de personalidad es un comando:

python scripts/change_personality.py vendedora

El sistema genera el prompt del sistema dinámicamente según la personalidad activa y el tipo de consulta:

def build_system_prompt(self, query="", media_type="text"):
    prompt = f"Soy {self.name}, {self.role} en {self.company}. "
    prompt += self.personality

    if "precio" in query.lower():
        prompt += self.price_prompt
    elif "garantía" in query.lower():
        prompt += self.warranty_prompt

    if media_type != "text":
        prompt += self.multimedia_prompt

    return prompt

Si alguien pregunta por precios, el prompt incluye instrucciones específicas sobre cómo presentar planes de financiación. Si manda una foto de un producto, activa las instrucciones multimedia. El LLM recibe un contexto preciso para cada situación.

Cuándo derivar a un humano

No todo se resuelve con IA. El sistema calcula un score de confianza y decide si la respuesta es suficiente o si necesita intervención humana:

confidence = 0.8 if context and len(context) > 100 else 0.3

if response.get("fallback"):
    confidence *= 0.8
if response.get("free_tier"):
    confidence *= 0.7

# Consultas complejas bajan el umbral
complex_keywords = ["problema", "queja", "reclamo", "devolucion"]
threshold = 0.4 if any(kw in query.lower() for kw in complex_keywords) else 0.6

requires_human = confidence < threshold

Si alguien dice “tengo un problema con mi pedido”, el umbral baja a 0.4 — el sistema es más propenso a derivar porque una queja mal manejada por IA puede escalar. Si la pregunta es “¿cuánto cuesta la olla FLIP?”, el umbral es 0.6 y probablemente el RAG tenga la respuesta exacta.

Lo que realmente cuesta

Desglose mensual para ~1000 mensajes/día:

ComponenteCosto
GPT-4o mini (input ~300 tokens × 1000)~$1.35/mes
GPT-4o mini (output ~200 tokens × 1000)~$3.60/mes
WhatsApp Business (mensajes dentro de 24h)$0
ChromaDB$0 (local)
Embeddings (all-MiniLM-L6-v2)$0 (local)
Total~$5-12/mes

El rango depende del volumen real de mensajes y de cuántos incluyen multimedia (que consumen más tokens). Los meses pico, con promociones, no pasaron de $12.

El modelo de embeddings corre local. La base vectorial corre local. El único costo variable es la API de OpenAI, y GPT-4o mini es absurdamente barato para lo que te da.

Lo que haría diferente

Memoria de conversación. El sistema actual no mantiene contexto entre mensajes. Cada pregunta arranca de cero. Para un bot de ventas, esto es una limitación real: el cliente dice “quiero la olla roja” y después pregunta “¿tiene envío gratis?” y el bot no tiene idea de qué olla le estás hablando.

Cache semántico. Muchas preguntas se repiten: “¿cuáles son los medios de pago?”, “¿hacen envíos al interior?”. Un cache que detecte preguntas semánticamente similares evitaría llamadas innecesarias al LLM.

FAQ poblado. La carpeta knowledge_base/faq/ existe pero está vacía. Las preguntas reales de los clientes son el mejor input para mejorar la base de conocimiento, y no las estamos capturando.

Fine-tuning del modelo de embeddings. all-MiniLM-L6-v2 es generalista. Un modelo fine-tuned con vocabulario de cocina y productos Essen mejoraría la precisión de búsqueda, especialmente para nombres de productos que no son palabras comunes.

El punto

RAG no tiene por qué ser complejo. ChromaDB + un modelo de embeddings local + GPT-4o mini resuelven el 80% de los casos de uso de chatbots empresariales. El otro 20% es ingeniería de producto: decidir cuándo derivar a un humano, cómo manejar multimedia, qué personalidad usar para cada contexto.

La infraestructura más cara de este proyecto es el conocimiento del dominio. Los documentos Markdown de la base de conocimiento se escriben a mano, se actualizan manualmente, y son lo que hace que el bot te sirva en serio en vez de tirar respuestas genéricas.

La IA es el canal. El valor está en lo que sabe.