chatbot
chatbot
Section titled “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
Estructura
Section titled “Estructura”app/ dashboard Next.js (proyectos, files, chat de prueba, admin)domain/ entidades + ports (interfaces), sin dependencias externasinfrastructure/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 versionadasdocs/adr/ decisiones de arquitecturaSetup local
Section titled “Setup local”npm installcp .env.local.example .env.local # completar con las credenciales del proyecto Supabase realnpm run devVariables 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)”- En
/files(app/files/page.tsx) admin elige proyecto + idioma, sube archivo. Va directo a Supabase Storage (bucketfiles) con path${projectId}/${language}/${filename}— path codifica tenant e idioma, sin columnas extra en form. - Trigger
on_file_uploadsobrestorage.objects(private.handle_storage_update(), migración20260810120000) corre en cada insert: parseaproject_id/language/filenamedepath_tokens, inserta fila endocuments, dispara viapg_net(net.http_post) POST asíncrono a Edge Functionprocess. process(supabase/functions/process/index.ts) descarga archivo de Storage, parsea según extensión:.md/.markdown→_lib/markdown-parser.ts: parsea a árbolmdast, corta por heading (splitTreeBy), cada sección mantiene heading. Sección > 2500 chars igual se sub-corta por longitud fija..pdf→_lib/pdf-parser.tsextrae 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_domparsea DOM, elimina ruido (script, style, noscript, nav, header, footer, svg), toma<main>o<body>, texto resultante pasa por mismochunkTextde longitud fija que PDF.- Cualquier otra extensión → 400
Unsupported file type.
- Cada sección se inserta en
document_sections(columnasdocument_id,project_id,content,language— heredado del documento padre,embeddingquedaNULLtodavía). - Insert dispara otro trigger,
embed_document_sections(private.embed(), migración20231007002735): agrupa filas insertadas en batches (default 5), por cada batch hace otronet.http_posta Edge Functionembed, pasandoids,table,contentColumn(content),embeddingColumn(embedding). embed(supabase/functions/embed/index.ts) selecciona esas filas (where id in (...) and embedding is null— evita reprocesar), por cada una llamaSupabaseAiEmbeddingAdapter.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 guardaJSON.stringify-ado en columnaembedding(tipovector(384)de pgvector, acepta el literal"[0.1,0.2,...]")./filesrefleja progreso sin polling activo: deriva badge (“Procesando…” / “Generando embeddings…” / “Listo”) comparando cuántasdocument_sectionstiene doc y si alguna tieneembedding 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.
2. Guardado en pgvector
Section titled “2. Guardado en pgvector”- Extensión
vector(pgvector) habilitada desde migración inicial (20231006212813_documents.sql). document_sections.embeddingesvector(384)— dimensión fija degte-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_opsindexa por inner product, no distancia euclídea ni cosine directo. - Todos vectores normalizados a longitud 1 (
normalize: truetanto engte-smallserver-side como@xenova/transformersclient-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.
3. Consulta del usuario (retrieval)
Section titled “3. Consulta del usuario (retrieval)”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)haceimport()dinámico de@xenova/transformersdesde CDN (esm.sh), arma pipelinefeature-extractionconSupabase/gte-small(lazy, cacheado enembeddingPipelinePromisetras primera pregunta — evita bajar modelo ~30MB si visitante nunca escribe), correextractor(text, { pooling: 'mean', normalize: true }). - Chat de prueba del dashboard (
components/chat-interface.tsx): mismo patrón vía hooklib/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):
- Resuelve
project_iddesdeproject_keyvíaapiKeys.findByPublicKey(tablaproject_api_keys) — key pública no es secreta, límite de seguridad real es paso 2. - Valida header
Originde request contraallowed_originsdel 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íaOriginreal que llega achat(sería siempre el nuestro, no del sitio embebido), rompiendo esta validación. - Llama
vectorSearch.matchDocumentSections(projectId, embedding, 0.65, page_language)→ RPC Postgresmatch_document_sections(adapterSupabaseVectorSearchAdapter,.rpc()+.select('content').limit(5)). - Si no hay resultados y
page_language !== 'en', reintenta una vez filtrandolanguage = 'en'como fallback (documento en inglés mejor que ninguno), ajustaresponseLanguageacorde.
Función SQL match_document_sections (última versión, migración
20260810120000):
select * from document_sectionswhere document_sections.project_id = filter_project_id and (filter_language is null or document_sections.language = filter_language) and document_sections.embedding <#> embedding < -match_thresholdorder 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.
4. Generación de la respuesta (LLM)
Section titled “4. Generación de la respuesta (LLM)”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únpage_languageque mandó widget (document.documentElement.langdel 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.
5. Escalamiento a agente humano
Section titled “5. Escalamiento a agente humano”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).
Aislamiento multi-tenant, en cada capa
Section titled “Aislamiento multi-tenant, en cada capa”- Dato:
project_idendocuments/document_sections, filtrado obligatorio (no opcional) enmatch_document_sections. - Auth del widget público: sin
auth.uid()(visitante anónimo) — límite esproject_key(identifica proyecto) +allowed_origins(autoriza dominio que puede usarlo). Ver ADR 001. - RLS:
projects/project_api_keys/documents/document_sectionstienen policies porowner_id/project_idpara dashboard autenticado;chat/escalatecorren conservice_role(bypassa RLS a propósito, ya validaron tenant porproject_key+Originantes de tocar datos). - Storage: policies de
storage.objectsvalidan quepath_tokens[1](project_iddel path) pertenezca a usuario autenticado que sube.
Edge Functions
Section titled “Edge Functions”process— ingesta: descarga de Storage, parsea (md/pdf/html), chunkea, insertadocument_sectionsembed— genera embeddings por batch (trigger SQL sobredocument_sections,Supabase.ai.Session('gte-small'))chat— responde preguntas del widget: auth porproject_key+allowed_origins, retrieval pgvector filtrado por proyecto/idioma, prompt+llamada a OpenAI, detecciónNO_ANSWERescalate— deriva a agente humano vía Telegram cuandochatdevolvióNO_ANSWER
Deploy: npx supabase@latest functions deploy <nombre>.