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:

  1. 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).
  2. 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 embeddingsembeddings_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:

  1. Embebe la consulta con input_type="search_query" (sub-espacio distinto al de search_document).
  2. KNN sobre embeddings_vec con sobre-recuperación de k * 4.
  3. Filtra por proyecto + doc_type opcional en el JOIN de SQL.
  4. Devuelve los top-K pasajes ordenados por distancia L2 ascendente.

Fachadas públicas


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


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


11. Referencias