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ć:

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

  1. Embedding zapytania z input_type="search_query" (inna podprzestrzeń niż search_document).
  2. KNN po embeddings_vec z overfetchem k * 4.
  3. Filtrowanie po projekcie + opcjonalnym doc_type na poziomie JOIN-a SQL.
  4. Zwrot top-K fragmentów posortowanych rosnąco po dystansie L2.

Publiczne fasady


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


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


11. Odniesienia