Architektura RAG — self-hosted wyszukiwanie semantyczne
Status: Działa od Phase 71 (2026-06-05). Zastępuje bridge Google NotebookLM dostarczony w Phase 36.
TL;DR: wiki + zgłoszenia + skille każdego projektu Arc są osadzane (embedding) w per-projektowym indeksie wektorowym, który żyje wewnątrz istniejącego SQLite SSOT. Zapytania zwracają semantycznie trafne fragmenty z treści projektu + współdzielonej globalnej puli skilli. Bez zewnętrznego serwisu retrieval, bez sesji Google per użytkownik, bez limitów źródeł.
1. Dlaczego odeszliśmy od NotebookLM
Bridge z Phase 36 opakowywał Google NotebookLM jako magazyn wektorów, używając reverse-engineerowanego uwierzytelniania cookie. Generował dwa problemy, których produkt bazowy nie jest w stanie naprawić:
- Limit źródeł. NotebookLM Plus kończy się na 100 źródłach na notebook. Do połowy 2026 kilka projektów Arc przebiło limit; logika ewikcji stała się permanentnym plastrem (
#324). - Sprzężenie z kontem osobistym. Każdy nowy projekt tworzył notebook wewnątrz osobistego NotebookLM CEO. Skalowanie do N klientów oznaczało N notebooków w sidebarze jednej osoby — niezarządzany efekt uboczny, którego bridge nie potrafił rozprząc.
Phase 71 wymienia warstwę retrieval na self-hosted pipeline, którego konto Google użytkownika nigdy nie dotyka.
2. Mapa komponentów
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 Moduły w skrócie
| Moduł | Rola |
|---|---|
shared/embeddings.ts |
Klient Cohere — embedBatch(texts, inputType) do indeksowania + embedQuery(text) do zapytań. Retry z 3× wykładniczym backoffem przy błędach przejściowych, fail-fast przy 401/403. |
shared/rag.ts |
Warstwa magazynu — upsert / search / removeDoc / removeProject / stats. Posiada chunker i atomową transakcję embeddings↔embeddings_vec. |
shared/rag-hooks.ts |
Wrappery fire-and-forget (syncIssue, syncWiki, syncSkill + lustrzane remove*). Błędy są wypisywane, ale nigdy nie cofają zapisu na dysk/SQL, który je wywołał. |
shared/routes/rag-search.ts |
Publiczny punkt wejścia HTTP. GET /api/crm/projects/:name/rag/search?q=... |
scripts/phase-71-backfill-rag.ts |
Jednorazowy idempotentny seeder dla istniejącej treści. Flagi --dry-run / --force / --project / --throttle. |
3. Wybór embeddingów — Cohere embed-multilingual-v3.0
| Właściwość | Wartość |
|---|---|
| Wymiarowość | 1024-dim float32 |
| Wielojęzyczność | Natywna — ukraiński + angielski + 100+ języków dzielą wspólną podprzestrzeń |
| Koszt | ~$0.10 / 1M tokenów (tier Production). Szacunkowo ~$4/miesiąc przy skali 50 użytkowników wg modelu kosztów. |
| Podział podprzestrzeni | input_type="search_document" do indeksowania vs input_type="search_query" do zapytań runtime. Mieszanie obu zabija recall. |
Live cross-lingual smoke na korpusie produkcyjnym:
| Zapytanie | Język | Najlepsze trafienie | Dystans |
|---|---|---|---|
| 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 |
Zapytanie UK pobiło odpowiadający dystans EN-EN — wyrównanie wielojęzycznej podprzestrzeni jest realne, to nie marketingowy slogan.
4. Magazyn — sqlite-vec wewnątrz SSOT
Migracja 049_embeddings tworzy dwie tabele:
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
Podział utrzymuje metadane w zwykłym SQLite (joinowalne z tabelami wiki/issues/skills, łatwe LIST/DELETE), podczas gdy wirtualna tabela vec0 trzyma gęste wektory do wyszukiwania ANN.
Rozmiary. 1024-dim float32 = 4096 bajtów / wiersz. 100K wierszy ≈ 400MB. W porządku przy obecnej skali; do rewizji przy 1M+ wierszy.
Ładowanie. sqlite-vec jest ładowane jako rozszerzenie runtime w shared/db.ts initDb() przed uruchomieniem migracji. Migracja 049 sprawdza vec_version() i przerywa czysto, jeśli rozszerzenie się nie załadowało.
5. Ścieżka zapisu — hooki re-embed (Phase 71.5)
Każda powierzchnia zapisu mutująca indeksowalną treść wywołuje hook fire-and-forget, dzięki czemu embedding pozostaje świeży bez osobnego joba synchronizacji.
| Trigger | Hook | Uwagi |
|---|---|---|
PUT /api/crm/projects/:name/wiki/save |
syncWiki(project, path, content) |
Po pomyślnym Bun.write |
POST /api/mcp/issues/:project (create) |
syncIssue(project, id, title, body) |
Po zapisie do SQLite |
PUT /api/mcp/issues/:project/:id (update) |
syncIssue(project, id, title, body) |
Tak samo |
POST /api/mcp/issues/:project/:id/log (activity) |
brak | Log aktywności nie zmienia indeksowanego tytułu/treści — re-embedding paliłby quota na no-op |
PUT /api/crm/projects/:name/skills/save |
syncSkill(project, name, content) |
Skill per-projekt |
DELETE /api/crm/projects/:name/skills/delete |
removeSkill(project, name) |
Lustro |
POST /api/crm/skills (global create) |
syncSkill("_global_", name, content) |
Pula globalna |
PUT /api/crm/skills/:id (global update) |
syncSkill("_global_", name, content) |
Tylko gdy pole content faktycznie się zmieniło |
DELETE /api/crm/skills/:id (global delete) |
removeSkill("_global_", name) |
Lustro |
DELETE /api/auth/account (RODO art. 17) |
removeProject(name) per posiadany projekt |
Embeddingi nie przeżywają usunięcia konta |
Pipeline transkryptów (Phase 73.6): status → summarized → embedding → done |
upsert(project, "transcript", id, ragText) przez transcript-worker.ts |
ragText = tekst transkryptu + [Xs] frame descriptions + tldr/key_points/decisions/action_items podsumowania. Pomijane przy embed_to_rag=0. Niekrytyczne: błąd RAG i tak finalizuje job do done. |
Reguła: błędy Cohere wypisują jedną linię i giną. Nigdy nie cofają zapisu na dysk/SQL, który je wywołał. Trwałe awarie zbiegają się z powrotem przez nocny backfill, a nie przez retry na gorącej ścieżce.
6. Ścieżka odczytu — search() + fasady per powierzchnia
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
Pod maską:
- Embedding zapytania z
input_type="search_query"(inna podprzestrzeń niżsearch_document). - KNN po
embeddings_vecz overfetchemk * 4. - Filtrowanie po projekcie + opcjonalnym
doc_typena poziomie JOIN-a SQL. - Zwrot top-K fragmentów posortowanych rosnąco po dystansie L2.
Publiczne fasady
GET /api/crm/projects/:name/rag/search?q=...&k=...&include_global=true&doc_types=...— punkt wejścia HTTP. Zasilaarc kb search+ narzędzieask_notebooklmw czacie.chat.ts executeAskNotebooklm— uruchamia wyszukiwanie projektowe + globalne równolegle, scala po dystansie, przy najlepszym trafieniud > 1.6lub zerze wyników wraca do wyszukiwania po słowach kluczowych.skills.ts handleGenerateSkill— używa RAG jako retrieval stylu domowego, potem wywołuje Claude Sonnet do generacji.help.ts ragSemantic— czat Arc Help podaje trafienia projektowe + globalne jako kontekst dla modelu.
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)
Idempotentność. Każdy kandydat jest sprawdzany na obecność (project, doc_type, doc_id) w embeddings i pomijany, jeśli istnieje, chyba że ustawiono --force. Ponowne uruchomienia są zero-kosztowe w quota Cohere.
Uruchomienie produkcyjne (2026-06-05). 814 kandydatów w 20 projektach + _global_ (37 wiki + 482 zgłoszeń + 295 skilli). Pierwszy przebieg spalił miesięczny limit Trial 1000 wywołań na globalnych skillach. Po przejściu na klucz Production pozostałe 178 dokumentów zakończyło się w 57 s bez błędów. Łącznie zapisanych chunków: ~3 150.
8. Granice bezpieczeństwa
- Klucz API Cohere żyje w vaulcie (
COHERE_API_KEY), rotowany przez Platform Settings → RAG / Semantic search. PrzyciskTestwykonuje 1-tokenowe wywołanie embed dla potwierdzenia osiągalności. - Brak wycieku promptów do Cohere. Embeddingi są pochodnymi bag-of-words; odwrócenie ich z powrotem do tekstu źródłowego jest niepraktyczne. Polityka Cohere deklaruje, że nie trenują na ruchu API.
- Multi-tenancy. Nazwy projektów są kluczami przestrzeni nazw;
canAccessProjectchroni/api/crm/projects/:name/*zanim uruchomi się handler wyszukiwania. Globalne skille żyją w wartowniczej przestrzeni nazw_global_, z którą żadna nazwa projektu użytkownika nie może kolidować (walidator zabrania nazw zaczynających się od_). - RODO art. 17.
DELETE /api/auth/accountkaskadowo wywołujerag.removeProjectdla każdego posiadanego projektu, aby embeddingi nie przeżyły usunięcia.
9. Migracja z NotebookLM (jednorazowa, 2026-06-05)
| Krok | Wykonane |
|---|---|
| Klucz API Cohere + zakładka Platform Settings + live probe | #358 |
Rozszerzenie sqlite-vec + migracja 049 |
#359 |
Klient shared/embeddings.ts |
#360 |
Fasada shared/rag.ts |
#361 |
| Hooki re-embed przy zapisach wiki / zgłoszeń / skilli | #362 |
| Skrypt backfill + uruchomienie prod | #363 |
Wymiana miejsc wywołań (chat.ts, skills.ts, nowe /rag/search, arc kb search) |
#364 |
Wycofanie services/notebooklm-bridge/ + usunięcie projects.notebook_id (migracja 050) |
#365 |
| Dokumentacja (ta strona + 7 stubów językowych) | #366 |
| Walidacja soak | #367 |
10. Uwagi operacyjne
- Nazwa narzędzia pozostała
ask_notebooklm. Definicja narzędzia Anthropic jest przekazywana dalej do Claude w konwersacji Cloud PM. Zmiana nazwy zepsułaby trwające pętletool_use. Implementacja to teraz czysty RAG; publiczny kontrakt pozostał stabilny. - Audio overview zniknęło. Auto-generowane podsumowania audio NotebookLM nie miały odpowiednika w self-hosted pipeline.
POST /api/crm/projects/:name/memory/fetch-artifactzwraca teraz410 Gone. Jeśli będzie potrzebne z powrotem — to osobna phase (prawdopodobnie przebieg Whisper TTS po wiki projektu). - Przycisk re-index nadal działa.
POST /api/crm/projects/:name/memory/refreshteraz ponownie osadzaMANIFEST.md+ROADMAP.md+ kluczowe pliki w lokalnym magazynieembeddingszamiast wgrywać do NotebookLM. Zachowanie dla użytkownika: ten sam przycisk, ten sam efekt („wiedza projektu jest teraz świeża"), inny magazyn. - Brak URL-i notebooków.
GET /api/crm/projects/:name/notebookszwraca{ notebooks: [], retired: "phase-71.8" }, aby stare buildy frontendu nie dostawały 404. Zakładka Neural Memory w CRM zostanie wycofana w kolejnym przebiegu kosmetycznym.
11. Odniesienia
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- Zgłoszenia #321 (nadrzędne) · #358–#367 (pod-fazy) · #322 / #324 (zamknięte równolegle)
- Log decyzji:
docs/architecture/PHASE_71_RAG_MIGRATION.md