Numa AI nació de una pregunta simple: ¿por qué las empresas tienen que depender de soluciones de IA genéricas que no conocen su negocio? La respuesta fue construir una plataforma SaaS donde cada empresa pudiera desplegar asistentes IA entrenados con su propia documentación.

Este es el post técnico que me hubiera gustado leer antes de empezarlo.

La arquitectura base: multi-tenant desde el día uno

El primer gran desafío fue diseñar la base de datos para soportar múltiples empresas con datos completamente aislados. Cada empresa tiene sus propios asistentes, documentos, conversaciones y configuraciones.

Usé NestJS + Prisma + PostgreSQL. El modelo de datos orbita alrededor de la entidad Organization:

// Cada recurso pertenece a una Organization
model Assistant {
  id             String       @id @default(cuid())
  name           String
  systemPrompt   String
  organizationId String
  organization   Organization @relation(fields: [organizationId], references: [id])
  documents      Document[]
  conversations  Conversation[]
}

RBAC completo con roles a nivel de organización: Owner, Admin, Member. Cada endpoint valida que el usuario pertenezca a la organización del recurso que intenta acceder.

RAG: hacer que la IA conozca tus documentos

La parte más técnicamente interesante. RAG (Retrieval-Augmented Generation) permite que el asistente responda preguntas basándose en documentos reales de la empresa, no solo en su conocimiento general.

El pipeline de ingesta

  1. El usuario sube un documento (PDF, Word, texto plano)

  2. Se extrae el texto y se divide en chunks de ~500 tokens con solapamiento

  3. Cada chunk se convierte en un vector embedding usando OpenAI text-embedding-3-small

  4. Los vectores se guardan en pgvector (extensión de PostgreSQL)

El pipeline de búsqueda

Cuando el usuario hace una pregunta:

  1. La pregunta se convierte en embedding

  2. Se hace búsqueda de similitud coseno contra los chunks almacenados

  3. Los N chunks más relevantes se incluyen en el contexto del prompt

  4. El LLM responde basándose en esa información

// Búsqueda semántica con pgvector
async findSimilarChunks(embedding: number[], assistantId: string, limit = 5) {
  return this.prisma.$queryRaw`
    SELECT content, 1 - (embedding <=> ${embedding}::vector) as similarity
    FROM document_chunks
    WHERE assistant_id = ${assistantId}
    ORDER BY embedding <=> ${embedding}::vector
    LIMIT ${limit}
  `;
}

Soporte multi-proveedor: OpenAI, Anthropic y Gemini

Uno de los requisitos era que cada empresa pudiera usar el proveedor de IA que quisiera. La solución: una capa de abstracción con una interfaz común.

interface AIProvider {
  chat(messages: Message[], config: AssistantConfig): AsyncIterable;
  embed(text: string): Promise;
}

class OpenAIProvider implements AIProvider { ... }
class AnthropicProvider implements AIProvider { ... }
class GeminiProvider implements AIProvider { ... }

El factory decide qué proveedor usar según la configuración del asistente. Añadir un nuevo proveedor es implementar la interfaz, nada más.

El widget embebible

Para que cualquier empresa pudiera añadir el asistente a su web con una línea de código, construí un widget en JavaScript vanilla que:

  • Se carga como script externo (<script src="...">)

  • Crea un iframe con el chat aislado del CSS del sitio host

  • Se comunica con la API de Numa vía postMessage

  • Es totalmente configurable: posición, colores, mensaje inicial

El aislamiento fue clave: el widget no puede romper el CSS de la web que lo integra, y la web no puede acceder al contenido del iframe.

Agendamiento con Google Calendar

Uno de los casos de uso más pedidos: el asistente puede agendar reuniones directamente desde el chat. Integración con Google Calendar vía OAuth2:

  • El asistente detecta intención de agendar (via function calling)

  • Consulta disponibilidad del calendar configurado

  • Propone slots disponibles al usuario

  • Crea el evento y envía confirmación por email

MCP Tools: extender el asistente

Implementé soporte para Model Context Protocol, lo que permite conectar el asistente con herramientas externas: bases de datos, APIs, sistemas internos. El asistente puede ejecutar acciones reales, no solo responder preguntas.

Lo que salió mal (y cómo lo arreglé)

Chunking demasiado agresivo

Los primeros chunks eran demasiado pequeños. El contexto se partía en medio de ideas importantes y las respuestas eran incoherentes. Ajusté el tamaño y añadí solapamiento entre chunks.

Costes de embeddings

Recalcular embeddings cada vez que se actualizaba un documento era caro. Implementé un hash del contenido para solo recalcular cuando el texto cambia realmente.

Latencia en streaming

El streaming de respuestas era inconsistente. Hubo que ajustar el buffer del servidor y configurar correctamente los headers de response para SSE.

Conclusión

Construir Numa fue el proyecto más complejo que he afrontado: multi-tenant, RAG, múltiples providers de IA, widget embebible, integraciones con APIs externas. Cada pieza tiene sus propias complejidades.

Lo que me llevó más tiempo no fue el código, sino entender correctamente el dominio del problema y tomar las decisiones de arquitectura correctas al principio. Cuando la base está bien, construir encima es fluido.