Arquitectura RAG — búsqueda semántica auto-hospedada
Estado: Activo desde Phase 71 (2026-06-05). Reemplaza el bridge de Google NotebookLM que se entregó en Phase 36.
TL;DR: la wiki + issues + skills de cada proyecto Arc se embeben en un índice vectorial por proyecto que vive dentro del SQLite SSOT existente. Las consultas devuelven pasajes semánticamente relevantes del contenido del proyecto + un pool global compartido de skills. Sin servicio de recuperación externo, sin sesión de Google por usuario, sin límites de fuentes.
1. Por qué dejamos NotebookLM
El bridge de Phase 36 envolvía Google NotebookLM como vector store usando autenticación por cookies obtenida por ingeniería inversa. Produjo dos problemas que el producto subyacente no puede resolver:
- Límite de fuentes. NotebookLM Plus tiene un tope de 100 fuentes por notebook. A mediados de 2026 varios proyectos Arc habían superado el límite; la lógica de desalojo se convirtió en un parche permanente (
#324). - Acoplamiento a una cuenta personal. Cada proyecto nuevo creaba un notebook dentro del NotebookLM personal del CEO. Escalar a N clientes significaba N notebooks en el sidebar de una sola persona — un efecto secundario sin gestión que el bridge no podía desacoplar.
Phase 71 sustituye la capa de recuperación por un pipeline auto-hospedado que nunca toca la cuenta de Google del usuario.
2. Mapa de componentes
Write path Read path
────────── ─────────
wiki.ts handleWikiSave ────┐ chat.ts executeAskNotebooklm ──┐
cli-routes.ts handleCreate/ │
UpdateIssue ─────────────┤ shared/routes/rag-search.ts ──────┤
skills.ts handleSave/ │ GET /api/crm/projects/:name/ │
Delete + global CRUD ────┤ rag/search │
│ │
▼ ▼
shared/rag-hooks.ts shared/rag.ts search()
syncIssue / syncWiki / │ embeds query →
syncSkill (fire-and-forget) │ KNN over vec0 →
│ │ project + doc_type filter
▼ │ → top-K passages
shared/rag.ts upsert() │
paragraph-aware chunker (~1800 chars) │
→ atomic transaction: │
embeddings ⨯ embeddings_vec │
│ │
▼ │
shared/embeddings.ts ◄──────────────────────────── │
Cohere embed-multilingual-v3.0 │
(1024-dim float32, batch ≤96 inputs/call) │
│ │
▼ │
SQLite (data/citadel.db) ◄──────────────────────── ┘
┌──────────────────────┐ ┌─────────────────────┐
│ embeddings │ │ embeddings_vec │
│ (project, doc_type, │ │ (vec0 virtual, │
│ doc_id, chunk_ix, │ │ embedding FLOAT │
│ text, created_at) │ ──┤ [1024]) │
│ id ←→ rowid │ │ │
└──────────────────────┘ └─────────────────────┘
2.1 Módulos de un vistazo
| Módulo | Rol |
|---|---|
shared/embeddings.ts |
Cliente de Cohere — embedBatch(texts, inputType) para indexar + embedQuery(text) para consultas. Reintento con backoff exponencial 3× en errores transitorios, fail-fast en 401/403. |
shared/rag.ts |
Capa de almacenamiento — upsert / search / removeDoc / removeProject / stats. Es dueña del chunker y de la transacción atómica embeddings↔embeddings_vec. |
shared/rag-hooks.ts |
Wrappers fire-and-forget (syncIssue, syncWiki, syncSkill + sus espejos remove*). Los errores se imprimen pero nunca revierten la escritura en disco/SQL que los desencadenó. |
shared/routes/rag-search.ts |
Punto de entrada HTTP público. GET /api/crm/projects/:name/rag/search?q=... |
scripts/phase-71-backfill-rag.ts |
Sembrador idempotente de un solo paso para contenido existente. Flags --dry-run / --force / --project / --throttle. |
3. Elección de embeddings — Cohere embed-multilingual-v3.0
| Propiedad | Valor |
|---|---|
| Dimensionalidad | float32 de 1024 dimensiones |
| Multilingüe | Nativo — ucraniano + inglés + 100+ idiomas comparten un sub-espacio |
| Coste | ~$0.10 / 1M tokens (nivel Production). Estimado ~$4/mes a escala de 50 usuarios según el modelo de costes. |
| División de sub-espacios | input_type="search_document" para indexar vs input_type="search_query" para consultas en runtime. Mezclar los dos hunde el recall. |
Prueba cross-lingüe en vivo contra el corpus de producción:
| Consulta | Idioma | Mejor resultado | Distancia |
|---|---|---|---|
| Safari cookie auth bug | EN | issue/174 (auth/password reset) |
1.00 |
| множинні тенанти | UK | issue/49 (Phase 53.11.2 Multi-Worker TG Topics Mode) |
1.07 |
La consulta en UK superó la distancia EN-EN correspondiente — la alineación del sub-espacio multilingüe es real, no un reclamo de marketing.
4. Almacenamiento — sqlite-vec dentro del SSOT
La migración 049_embeddings crea dos tablas:
CREATE TABLE embeddings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project TEXT NOT NULL,
doc_type TEXT NOT NULL, -- 'wiki' | 'issue' | 'skill' | 'transcript'
doc_id TEXT NOT NULL, -- filename, issue id, skill name
chunk_ix INTEGER NOT NULL DEFAULT 0,
text TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE VIRTUAL TABLE embeddings_vec USING vec0(
embedding FLOAT[1024]
);
-- embeddings.id ←→ embeddings_vec.rowid
La división mantiene los metadatos en SQLite normal (con JOIN a las tablas wiki/issues/skills, LIST/DELETE fáciles) mientras la tabla virtual vec0 guarda los vectores densos para la búsqueda ANN.
Dimensionamiento. float32 de 1024 dim = 4096 bytes / fila. 100K filas ≈ 400MB. Adecuado a la escala actual; revisar a partir de 1M+ filas.
Carga. sqlite-vec se carga como extensión en runtime en shared/db.ts initDb() antes de ejecutar las migraciones. La migración 049 verifica vec_version() y aborta limpiamente si la extensión no se cargó.
5. Ruta de escritura — hooks de re-embedding (Phase 71.5)
Cada superficie de escritura que muta contenido indexable dispara un hook fire-and-forget para que el embedding se mantenga fresco sin un job de sincronización aparte.
| Disparador | Hook | Notas |
|---|---|---|
PUT /api/crm/projects/:name/wiki/save |
syncWiki(project, path, content) |
Tras el éxito de Bun.write |
POST /api/mcp/issues/:project (create) |
syncIssue(project, id, title, body) |
Tras la escritura en SQLite |
PUT /api/mcp/issues/:project/:id (update) |
syncIssue(project, id, title, body) |
Igual |
POST /api/mcp/issues/:project/:id/log (activity) |
ninguno | El log de actividad no cambia el título/cuerpo indexados — re-embeber quemaría cuota sin efecto |
PUT /api/crm/projects/:name/skills/save |
syncSkill(project, name, content) |
Skill por proyecto |
DELETE /api/crm/projects/:name/skills/delete |
removeSkill(project, name) |
Espejo |
POST /api/crm/skills (creación global) |
syncSkill("_global_", name, content) |
Pool global |
PUT /api/crm/skills/:id (actualización global) |
syncSkill("_global_", name, content) |
Solo si el campo content cambió realmente |
DELETE /api/crm/skills/:id (borrado global) |
removeSkill("_global_", name) |
Espejo |
DELETE /api/auth/account (RGPD Art. 17) |
removeProject(name) por cada proyecto del usuario |
Los embeddings no sobreviven a la eliminación de la cuenta |
Pipeline de transcripciones (Phase 73.6): status → summarized → embedding → done |
upsert(project, "transcript", id, ragText) vía transcript-worker.ts |
ragText = texto de la transcripción + descripciones de fotogramas [Xs] + tldr/key_points/decisions/action_items del resumen. Se omite cuando embed_to_rag=0. No fatal: el fallo de RAG aún finaliza el job a done. |
Regla: los errores de Cohere imprimen una línea y mueren. Nunca revierten la escritura en disco/SQL que los disparó. Los fallos persistentes convergen de nuevo mediante el backfill nocturno, no mediante reintentos en el hot path.
6. Ruta de lectura — search() + fachadas por superficie
import { search } from "shared/rag";
const hits = await search("arc-v2", "How does the multi-tenancy gate work?", {
k: 6,
doc_types: ["wiki", "issue", "skill", "transcript"], // optional narrow
});
// hits: Array<{ doc_type, doc_id, chunk_ix, text, distance }> sorted by distance
Por debajo:
- Embebe la consulta con
input_type="search_query"(sub-espacio distinto al desearch_document). - KNN sobre
embeddings_veccon sobre-recuperación dek * 4. - Filtra por proyecto +
doc_typeopcional en el JOIN de SQL. - Devuelve los top-K pasajes ordenados por distancia L2 ascendente.
Fachadas públicas
GET /api/crm/projects/:name/rag/search?q=...&k=...&include_global=true&doc_types=...— punto de entrada HTTP. Alimentaarc kb search+ la herramienta de chatask_notebooklm.chat.ts executeAskNotebooklm— ejecuta la búsqueda de proyecto + global en paralelo, fusiona por distancia, recurre a búsqueda por palabras clave cuando el mejor resultado tiened > 1.6o no hay resultados.skills.ts handleGenerateSkill— usa RAG como recuperación del estilo de la casa, luego llama a Claude Sonnet para la generación.help.ts ragSemantic— el chat de Arc Help expone resultados de proyecto + globales como contexto del modelo.
7. Backfill — scripts/phase-71-backfill-rag.ts
bun scripts/phase-71-backfill-rag.ts # all projects + global
bun scripts/phase-71-backfill-rag.ts --dry-run # inventory only
bun scripts/phase-71-backfill-rag.ts --project arc-v2 # one project + global
bun scripts/phase-71-backfill-rag.ts --skip-global # projects only
bun scripts/phase-71-backfill-rag.ts --doc-types wiki,issue
bun scripts/phase-71-backfill-rag.ts --force # re-embed even if row exists
bun scripts/phase-71-backfill-rag.ts --limit 100 # cap docs per project
bun scripts/phase-71-backfill-rag.ts --throttle 700 # ms between Cohere calls (default 700; set 0 on Production keys)
Idempotencia. Cada candidato se comprueba por la presencia de (project, doc_type, doc_id) en embeddings y se omite si existe, salvo que se use --force. Las re-ejecuciones tienen coste cero en cuota de Cohere.
Ejecución en producción (2026-06-05). 814 candidatos en 20 proyectos + _global_ (37 wiki + 482 issue + 295 skill). La primera pasada agotó el tope mensual de 1000 llamadas del nivel Trial en los skills globales. Tras pasar a una clave Production, los 178 docs restantes terminaron en 57 s sin errores. Total de chunks escritos: ~3,150.
8. Fronteras de seguridad
- La clave API de Cohere vive en el vault (
COHERE_API_KEY) y rota vía Platform Settings → RAG / Semantic search. El botónTesten vivo realiza una llamada de embed de 1 token para confirmar la conectividad. - Sin fuga de prompts a Cohere. Los embeddings son derivados tipo bag-of-words; revertirlos al texto fuente es impracticable. La propia política de Cohere indica que no entrenan con el tráfico de la API.
- Multi-tenancy. Los nombres de proyecto son claves de namespace;
canAccessProjectprotege/api/crm/projects/:name/*antes de que corra el handler de búsqueda. Los skills globales viven en un namespace centinela_global_con el que ningún nombre de proyecto de usuario puede colisionar (el validador prohíbe nombres que empiecen por_). - RGPD Art. 17.
DELETE /api/auth/accountejecuta en cascadarag.removeProjectpara cada proyecto del usuario, de modo que los embeddings no sobreviven a la eliminación.
9. Migración desde NotebookLM (única, 2026-06-05)
| Paso | Hecho |
|---|---|
| Clave API de Cohere + pestaña en Platform Settings + sonda en vivo | #358 |
Extensión sqlite-vec + migración 049 |
#359 |
Cliente shared/embeddings.ts |
#360 |
Fachada shared/rag.ts |
#361 |
| Hooks de re-embedding en escrituras de wiki / issue / skill | #362 |
| Script de backfill + ejecución en producción | #363 |
Cambio de los puntos de llamada (chat.ts, skills.ts, nuevo /rag/search, arc kb search) |
#364 |
Desmantelamiento de services/notebooklm-bridge/ + eliminación de projects.notebook_id (migración 050) |
#365 |
| Docs (esta página + 7 stubs de locales) | #366 |
| Validación de soak | #367 |
10. Notas operativas
- El nombre de la herramienta se mantiene como
ask_notebooklm. La definición de la herramienta de Anthropic se envía río abajo a Claude en la conversación del Cloud PM. Renombrarla rompería los buclestool_useen curso. La implementación ahora es RAG puro; el contrato público quedó estable. - El audio overview desapareció. Los resúmenes de audio autogenerados de NotebookLM no tienen equivalente en el pipeline auto-hospedado.
POST /api/crm/projects/:name/memory/fetch-artifactahora devuelve410 Gone. Si se necesita de vuelta, es una fase aparte (probablemente una pasada de Whisper TTS sobre la wiki del proyecto). - El botón de re-indexado sigue funcionando.
POST /api/crm/projects/:name/memory/refreshahora re-embebeMANIFEST.md+ROADMAP.md+ archivos clave en el almacén localembeddingsen lugar de subir a NotebookLM. Comportamiento de cara al usuario: mismo botón, mismo resultado ("project knowledge is now fresh"), distinto almacenamiento. - Sin URLs de notebooks.
GET /api/crm/projects/:name/notebooksdevuelve{ notebooks: [], retired: "phase-71.8" }para que los builds antiguos del frontend no den 404. La pestaña Neural Memory del CRM se retirará en una pasada cosmética posterior.
11. Referencias
shared/embeddings.ts,shared/rag.ts,shared/rag-hooks.ts,shared/routes/rag-search.tsshared/migrations/049_embeddings.ts,shared/migrations/050_drop_notebook_id.tsscripts/phase-71-backfill-rag.ts- Issues #321 (padre) · #358–#367 (sub-fases) · #322 / #324 (cerrados en paralelo)
- Registro de decisiones:
docs/architecture/PHASE_71_RAG_MIGRATION.md