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
El usuario sube un documento (PDF, Word, texto plano)
Se extrae el texto y se divide en chunks de ~500 tokens con solapamiento
Cada chunk se convierte en un vector embedding usando OpenAI text-embedding-3-small
Los vectores se guardan en pgvector (extensión de PostgreSQL)
El pipeline de búsqueda
Cuando el usuario hace una pregunta:
La pregunta se convierte en embedding
Se hace búsqueda de similitud coseno contra los chunks almacenados
Los N chunks más relevantes se incluyen en el contexto del prompt
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.