Skip to content

chatbot

Chatbot RAG embebible multi-tenant: un solo backend, varios sitios embeben widget (<script> + Shadow DOM), cada uno con base de conocimiento propia (documentos + embeddings) aislada por proyecto/API key, sin duplicar infra por cliente.

Basado en arquitectura de chatgpt-your-files (Next.js + Supabase pgvector), pipeline de ingesta simplificado.

  • Backend: Supabase (Postgres + pgvector, Storage, Edge Functions, RLS por proyecto)
  • Dashboard: Next.js 14 (App Router) + TypeScript, arquitectura hexagonal (domain/ puro, infrastructure/supabase/ adapters, composition/ root)
  • Widget embebible: widget/widget.js — script standalone, Shadow DOM, embeddings client-side vía @xenova/transformers
  • RAG: chunking + embeddings (gte-small, 384 dims), pgvector (HNSW, inner product), filtrado por proyecto y por idioma
  • Escalamiento: a agente humano vía Telegram cuando LLM no sabe responder
app/ dashboard Next.js (proyectos, files, chat de prueba, admin)
domain/ entidades + ports (interfaces), sin dependencias externas
infrastructure/supabase/ adapters Supabase por port (auth, repos, storage, vector-search, embedding)
composition/ factories que inyectan adapters en los ports (composition root)
widget/ widget.js embebible (entry point independiente del dashboard)
supabase/functions/ Edge Functions: chat, process, embed, escalate (+ _lib con su propia copia de domain/infrastructure, corre en Deno)
supabase/migrations/ migraciones SQL versionadas
docs/adr/ decisiones de arquitectura
Terminal window
npm install
cp .env.local.example .env.local # completar con las credenciales del proyecto Supabase real
npm run dev

Variables requeridas en .env.local: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY.

Otros scripts: npm run build, npm test (Vitest), npm run gen:types (regenera tipos desde schema local Supabase).

Cómo funciona el RAG — de punta a punta

Section titled “Cómo funciona el RAG — de punta a punta”

Pipeline entero usa un solo modelo de embeddings (Supabase/gte-small, 384 dims, normalizado) en tres puntos donde hace falta vectorizar texto: ingesta de documentos, chat de prueba del dashboard, widget público. Nunca mezcla con otro proveedor — mismo modelo en servidor (Edge Runtime) y browser (@xenova/transformers, WASM/ONNX), así vectores de pregunta y documento viven en mismo espacio vectorial, comparables.

1. Ingesta de un documento (/files → Storage → process → embed)

Section titled “1. Ingesta de un documento (/files → Storage → process → embed)”
  1. En /files (app/files/page.tsx) admin elige proyecto + idioma, sube archivo. Va directo a Supabase Storage (bucket files) con path ${projectId}/${language}/${filename} — path codifica tenant e idioma, sin columnas extra en form.
  2. Trigger on_file_upload sobre storage.objects (private.handle_storage_update(), migración 20260810120000) corre en cada insert: parsea project_id/language/filename de path_tokens, inserta fila en documents, dispara via pg_net (net.http_post) POST asíncrono a Edge Function process.
  3. process (supabase/functions/process/index.ts) descarga archivo de Storage, parsea según extensión:
    • .md/.markdown → _lib/markdown-parser.ts: parsea a árbol mdast, corta por heading (splitTreeBy), cada sección mantiene heading. Sección > 2500 chars igual se sub-corta por longitud fija.
    • .pdf → _lib/pdf-parser.ts extrae texto plano, después _lib/text-chunker.ts (chunkText) corta por longitud fija (~2500 chars), sin estructura heading.
    • .html/.htm → _lib/html-parser.ts: deno_dom parsea DOM, elimina ruido (script, style, noscript, nav, header, footer, svg), toma <main> o <body>, texto resultante pasa por mismo chunkText de longitud fija que PDF.
    • Cualquier otra extensión → 400 Unsupported file type.
  4. Cada sección se inserta en document_sections (columnas document_id, project_id, content, language — heredado del documento padre, embedding queda NULL todavía).
  5. Insert dispara otro trigger, embed_document_sections (private.embed(), migración 20231007002735): agrupa filas insertadas en batches (default 5), por cada batch hace otro net.http_post a Edge Function embed, pasando ids, table, contentColumn (content), embeddingColumn (embedding).
  6. embed (supabase/functions/embed/index.ts) selecciona esas filas (where id in (...) and embedding is null — evita reprocesar), por cada una llama SupabaseAiEmbeddingAdapter.generate() → Supabase.ai.Session('gte-small').run(content, { mean_pool: true, normalize: true }), motor de embeddings built-in del Edge Runtime (sin dependencia npm, sin llamada a proveedor externo). Vector resultante (384 floats) se guarda JSON.stringify-ado en columna embedding (tipo vector(384) de pgvector, acepta el literal "[0.1,0.2,...]").
  7. /files refleja progreso sin polling activo: deriva badge (“Procesando…” / “Generando embeddings…” / “Listo”) comparando cuántas document_sections tiene doc y si alguna tiene embedding IS NULL.

Tramo 2→6 entero es asíncrono, encadenado por triggers Postgres — browser del admin nunca espera a que termine chunking ni embedding, solo hace upload inicial.

  • Extensión vector (pgvector) habilitada desde migración inicial (20231006212813_documents.sql).
  • document_sections.embedding es vector(384) — dimensión fija de gte-small, no configurable sin migrar todos vectores existentes.
  • Índice hnsw (embedding vector_ip_ops): HNSW (Hierarchical Navigable Small World) = índice aproximado de vecinos más cercanos; vector_ip_ops indexa por inner product, no distancia euclídea ni cosine directo.
  • Todos vectores normalizados a longitud 1 (normalize: true tanto en gte-small server-side como @xenova/transformers client-side), inner product y similitud coseno dan mismo ranking — usa inner product por más barato de calcular, evita paso extra de normalizar en cada query.

Embedding de pregunta nunca se genera en servidor — genera en cliente (browser del visitante o admin), viaja ya calculado en body del POST a chat. Dos entry points, mismo modelo:

  • Widget público (widget/widget.js): al enviar mensaje, embed(text) hace import() dinámico de @xenova/transformers desde CDN (esm.sh), arma pipeline feature-extraction con Supabase/gte-small (lazy, cacheado en embeddingPipelinePromise tras primera pregunta — evita bajar modelo ~30MB si visitante nunca escribe), corre extractor(text, { pooling: 'mean', normalize: true }).
  • Chat de prueba del dashboard (components/chat-interface.tsx): mismo patrón vía hook lib/hooks/use-pipeline.ts (usePipeline('feature-extraction', 'Supabase/gte-small')), mismos parámetros pooling/normalize.

POST a Edge Function chat manda { project_key, messages, embedding, page_language }. chat (supabase/functions/chat/index.ts):

  1. Resuelve project_id desde project_key vía apiKeys.findByPublicKey (tabla project_api_keys) — key pública no es secreta, límite de seguridad real es paso 2.
  2. Valida header Origin de request contra allowed_origins del proyecto — 403 si no matchea. Ver ADR 002: por eso widget corre como <script> inyectado en documento del sitio cliente, nunca en <iframe> propio — un iframe cambiaría Origin real que llega a chat (sería siempre el nuestro, no del sitio embebido), rompiendo esta validación.
  3. Llama vectorSearch.matchDocumentSections(projectId, embedding, 0.65, page_language) → RPC Postgres match_document_sections (adapter SupabaseVectorSearchAdapter, .rpc() + .select('content').limit(5)).
  4. Si no hay resultados y page_language !== 'en', reintenta una vez filtrando language = 'en' como fallback (documento en inglés mejor que ninguno), ajusta responseLanguage acorde.

Función SQL match_document_sections (última versión, migración 20260810120000):

select * from document_sections
where document_sections.project_id = filter_project_id
and (filter_language is null or document_sections.language = filter_language)
and document_sections.embedding <#> embedding < -match_threshold
order by document_sections.embedding <#> embedding;

<#> es operador de inner product negativo de pgvector — de ahí < -match_threshold (signo se invierte). Filtra primero por project_id (aislamiento multi-tenant real, no opcional) y language (opcional, null = sin filtrar), recién ahí ordena/filtra por similitud vectorial. match_threshold = 0.65 hardcodeado en chat/index.ts, no configurable por proyecto todavía.

Con secciones que matchearon (content de hasta 5 filas, concatenadas con \n\n), chat arma prompt del sistema (OpenAiChatAdapter, wrapper fino sobre SDK de openai):

  • Instruye modelo a responder solo con esos documentos.
  • Si pregunta no se puede responder con ellos, prompt pide responder con token literal NO_ANSWER — detección determinística por comparación exacta de string, no parseo de lenguaje natural sobre respuesta real.
  • Fuerza idioma de respuesta (LANGUAGE_NAMES[responseLanguage]) según page_language que mandó widget (document.documentElement.lang del sitio anfitrión), sin importar idioma del documento fuente ni pregunta del usuario.

Si respuesta del LLM es exactamente NO_ANSWER, chat no la reenvía: devuelve apiKey.noAnswerMessage (texto configurable por proyecto) o default bilingüe es/en, más escalation_available: true si proyecto tiene telegram_chat_id configurado.

Si escalation_available viene en respuesta, widget muestra form de email (renderEscalationForm), postea a escalate (mismo host que chat, resuelto con endpoint.replace(/\/chat$/, '/escalate'), sin data-* nuevo en <script>). escalate/index.ts repite mismas validaciones de project_key+Origin, notifica vía TelegramNotifierAdapter (fetch directo a api.telegram.org/bot<token>/sendMessage) al telegram_chat_id del proyecto — un solo bot compartido entre todos tenants, cada proyecto solo aporta su chat de destino. Sin tabla de tracking en DB (decisión YAGNI — agente responde por fuera del sistema, por email/Telegram directo).

  • Dato: project_id en documents/document_sections, filtrado obligatorio (no opcional) en match_document_sections.
  • Auth del widget público: sin auth.uid() (visitante anónimo) — límite es project_key (identifica proyecto) + allowed_origins (autoriza dominio que puede usarlo). Ver ADR 001.
  • RLS: projects/project_api_keys/documents/document_sections tienen policies por owner_id/project_id para dashboard autenticado; chat/escalate corren con service_role (bypassa RLS a propósito, ya validaron tenant por project_key+Origin antes de tocar datos).
  • Storage: policies de storage.objects validan que path_tokens[1] (project_id del path) pertenezca a usuario autenticado que sube.
  • process — ingesta: descarga de Storage, parsea (md/pdf/html), chunkea, inserta document_sections
  • embed — genera embeddings por batch (trigger SQL sobre document_sections, Supabase.ai.Session('gte-small'))
  • chat — responde preguntas del widget: auth por project_key + allowed_origins, retrieval pgvector filtrado por proyecto/idioma, prompt+llamada a OpenAI, detección NO_ANSWER
  • escalate — deriva a agente humano vía Telegram cuando chat devolvió NO_ANSWER

Deploy: npx supabase@latest functions deploy <nombre>.

Ver diagrama interactivo