Architecture RAG — recherche sémantique auto-hébergée

Statut : En production depuis la Phase 71 (2026-06-05). Remplace le bridge Google NotebookLM livré en Phase 36.

TL;DR : le wiki + les tickets + les skills de chaque projet Arc sont indexés dans un index vectoriel par projet qui vit à l'intérieur du SSOT SQLite existant. Les requêtes renvoient des passages sémantiquement pertinents à travers le contenu du projet + un pool global de skills partagé. Aucun service de récupération externe, aucune session Google par utilisateur, aucune limite de sources.


1. Pourquoi nous avons quitté NotebookLM

Le bridge de la Phase 36 enrobait Google NotebookLM en magasin de vecteurs via une auth par cookies rétro-ingéniérée. Cela produisait deux problèmes que le produit sous-jacent ne peut pas corriger :

  1. Plafond de sources. NotebookLM Plus plafonne à 100 sources par notebook. Mi-2026, plusieurs projets Arc avaient largement dépassé ce plafond ; la logique d'éviction est devenue un pansement permanent (#324).
  2. Couplage au compte personnel. Chaque nouveau projet créait un notebook dans le NotebookLM personnel du CEO. Passer à N clients signifiait N notebooks dans la barre latérale d'une seule personne — un effet de bord non géré que le bridge ne pouvait pas découpler.

La Phase 71 remplace la couche de récupération par un pipeline auto-hébergé que le compte Google de l'utilisateur ne touche jamais.


2. Carte des composants

                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 Les modules en un coup d'œil

Module Rôle
shared/embeddings.ts Client Cohere — embedBatch(texts, inputType) pour l'indexation + embedQuery(text) pour les requêtes. Retry avec backoff exponentiel ×3 sur les erreurs transitoires, fail-fast sur 401/403.
shared/rag.ts Couche de stockage — upsert / search / removeDoc / removeProject / stats. Possède le chunker et la transaction atomique embeddingsembeddings_vec.
shared/rag-hooks.ts Wrappers fire-and-forget (syncIssue, syncWiki, syncSkill + miroirs remove*). Les erreurs sont affichées mais n'annulent jamais l'écriture disque/SQL qui les a déclenchées.
shared/routes/rag-search.ts Point d'entrée HTTP public. GET /api/crm/projects/:name/rag/search?q=...
scripts/phase-71-backfill-rag.ts Seeder idempotent en un seul passage pour le contenu existant. Flags --dry-run / --force / --project / --throttle.

3. Choix d'embedding — Cohere embed-multilingual-v3.0

Propriété Valeur
Dimensionnalité float32 1024 dimensions
Multilingue Natif — l'ukrainien + l'anglais + 100+ langues partagent un sous-espace commun
Coût ~0,10 $ / 1M tokens (tier Production). Estimé ~4 $/mois à l'échelle de 50 utilisateurs selon le modèle de coûts.
Séparation des sous-espaces input_type="search_document" pour l'indexation vs input_type="search_query" pour les requêtes à l'exécution. Mélanger les deux effondre le rappel.

Test croisé multilingue en direct contre le corpus de prod :

Requête Langue Meilleur résultat Distance
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 requête UK a battu la distance EN-EN correspondante — l'alignement du sous-espace multilingue est réel, pas un argument marketing.


4. Stockage — sqlite-vec à l'intérieur du SSOT

La migration 049_embeddings crée deux tables :

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

Cette séparation garde les métadonnées dans du SQLite classique (jointables avec les tables wiki/issues/skills, LIST/DELETE faciles) tandis que la table virtuelle vec0 contient les vecteurs denses pour la recherche ANN.

Dimensionnement. float32 1024 dimensions = 4096 octets / ligne. 100K lignes ≈ 400 Mo. Suffisant à l'échelle actuelle ; à réexaminer au-delà de 1M de lignes.

Chargement. sqlite-vec est chargé comme extension runtime dans shared/db.ts initDb() avant l'exécution des migrations. La migration 049 vérifie vec_version() et s'interrompt proprement si l'extension n'a pas chargé.


5. Chemin d'écriture — hooks de ré-indexation (Phase 71.5)

Chaque surface d'écriture qui modifie du contenu indexable déclenche un hook fire-and-forget afin que l'embedding reste à jour sans job de synchronisation séparé.

Déclencheur Hook Notes
PUT /api/crm/projects/:name/wiki/save syncWiki(project, path, content) Après le succès de Bun.write
POST /api/mcp/issues/:project (création) syncIssue(project, id, title, body) Après l'écriture SQLite
PUT /api/mcp/issues/:project/:id (mise à jour) syncIssue(project, id, title, body) Idem
POST /api/mcp/issues/:project/:id/log (activité) aucun Le log d'activité ne change pas le titre/corps indexés — ré-indexer brûlerait du quota pour rien
PUT /api/crm/projects/:name/skills/save syncSkill(project, name, content) Skill par projet
DELETE /api/crm/projects/:name/skills/delete removeSkill(project, name) Miroir
POST /api/crm/skills (création globale) syncSkill("_global_", name, content) Pool global
PUT /api/crm/skills/:id (mise à jour globale) syncSkill("_global_", name, content) Seulement si le champ content a réellement changé
DELETE /api/crm/skills/:id (suppression globale) removeSkill("_global_", name) Miroir
DELETE /api/auth/account (RGPD Art. 17) removeProject(name) pour chaque projet possédé Les embeddings ne survivent pas à la suppression du compte
Pipeline transcription (Phase 73.6) : status → summarized → embedding → done upsert(project, "transcript", id, ragText) via transcript-worker.ts ragText = texte de transcription + descriptions d'images [Xs] + tldr/key_points/decisions/action_items du résumé. Sauté quand embed_to_rag=0. Non fatal : un échec RAG finalise quand même le job en done.

Règle : les erreurs Cohere affichent une ligne et meurent. Elles n'annulent jamais l'écriture disque/SQL qui les a déclenchées. Les échecs persistants reconvergent via le backfill nocturne, pas via des retries dans le chemin chaud.


6. Chemin de lecture — search() + façades par surface

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

Sous le capot :

  1. Embedding de la requête avec input_type="search_query" (sous-espace différent de search_document).
  2. KNN sur embeddings_vec avec un surfetch de k * 4.
  3. Filtrage par projet + doc_type optionnel au niveau du JOIN SQL.
  4. Retour des top-K passages triés par distance L2 croissante.

Façades publiques


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)

Idempotence. Chaque candidat est vérifié pour la présence de (project, doc_type, doc_id) dans embeddings et sauté s'il est trouvé, sauf si --force est passé. Les ré-exécutions coûtent zéro quota Cohere.

Exécution de production (2026-06-05). 814 candidats à travers 20 projets + _global_ (37 wiki + 482 tickets + 295 skills). Le premier passage a épuisé le plafond mensuel Trial de 1000 appels sur les skills globaux. Après passage à une clé Production, les 178 documents restants ont été traités en 57 s sans aucune erreur. Total de chunks écrits : ~3 150.


8. Frontières de sécurité


9. Migration depuis NotebookLM (unique, 2026-06-05)

Étape Fait
Clé API Cohere + onglet Platform Settings + sonde en direct #358
Extension sqlite-vec + migration 049 #359
Client shared/embeddings.ts #360
Façade shared/rag.ts #361
Hooks de ré-indexation sur les écritures wiki / tickets / skills #362
Script de backfill + exécution de prod #363
Bascule des points d'appel (chat.ts, skills.ts, nouveau /rag/search, arc kb search) #364
Mise hors service de services/notebooklm-bridge/ + suppression de projects.notebook_id (migration 050) #365
Docs (cette page + 7 stubs de locales) #366
Validation de stabilisation #367

10. Notes d'exploitation


11. Références