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 :
- 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). - 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 embeddings↔embeddings_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 :
- Embedding de la requête avec
input_type="search_query"(sous-espace différent desearch_document). - KNN sur
embeddings_vecavec un surfetch dek * 4. - Filtrage par projet +
doc_typeoptionnel au niveau du JOIN SQL. - Retour des top-K passages triés par distance L2 croissante.
Façades publiques
GET /api/crm/projects/:name/rag/search?q=...&k=...&include_global=true&doc_types=...— point d'entrée HTTP. Alimentearc kb search+ l'outilask_notebooklmen chat.chat.ts executeAskNotebooklm— exécute la recherche projet + globale en parallèle, fusionne par distance, bascule sur la recherche par mots-clés quand le meilleur résultat ad > 1.6ou en l'absence de résultats.skills.ts handleGenerateSkill— utilise le RAG comme récupération du style maison, puis appelle Claude Sonnet pour la génération.help.ts ragSemantic— le chat Arc Help expose les résultats projet + globaux comme contexte du modèle.
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é
- La clé API Cohere vit dans le vault (
COHERE_API_KEY), tourne via Platform Settings → RAG / Semantic search. Un boutonTesten direct effectue un appel d'embedding de 1 token pour confirmer la joignabilité. - Aucune fuite de prompts vers Cohere. Les embeddings sont des dérivés de type bag-of-words ; les inverser vers le texte source est impraticable. La politique de Cohere indique qu'ils n'entraînent pas leurs modèles sur le trafic API.
- Multi-tenancy. Les noms de projets sont des clés de namespace ;
canAccessProjectgarde/api/crm/projects/:name/*avant l'exécution du handler de recherche. Les skills globaux vivent dans un namespace sentinelle_global_avec lequel aucun nom de projet utilisateur ne peut entrer en collision (le validateur interdit les noms commençant par_). - RGPD Art. 17.
DELETE /api/auth/accountcascaderag.removeProjectpour chaque projet possédé afin que les embeddings ne survivent pas à la suppression.
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
- Le nom d'outil reste
ask_notebooklm. La définition d'outil Anthropic est transmise en aval à Claude dans la conversation Cloud PM. La renommer casserait les bouclestool_useen vol. L'implémentation est désormais du pur RAG ; le contrat public est resté stable. - L'audio overview a disparu. Les résumés audio auto-générés de NotebookLM n'ont pas d'équivalent dans le pipeline auto-hébergé.
POST /api/crm/projects/:name/memory/fetch-artifactrenvoie désormais410 Gone. Si tu en as besoin, c'est une phase séparée (probablement une passe Whisper TTS sur le wiki du projet). - Le bouton de ré-indexation fonctionne toujours.
POST /api/crm/projects/:name/memory/refreshré-indexe désormaisMANIFEST.md+ROADMAP.md+ les fichiers clés dans le store localembeddingsau lieu de les uploader vers NotebookLM. Comportement côté utilisateur : même bouton, même résultat (« la connaissance du projet est maintenant fraîche »), stockage différent. - Plus d'URL de notebooks.
GET /api/crm/projects/:name/notebooksrenvoie{ notebooks: [], retired: "phase-71.8" }pour éviter les 404 sur les anciens builds frontend. L'onglet Neural Memory du CRM sera retiré dans une passe cosmétique ultérieure.
11. Références
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- Tickets #321 (parent) · #358–#367 (sous-phases) · #322 / #324 (clos dans la foulée)
- Journal de décisions :
docs/architecture/PHASE_71_RAG_MIGRATION.md