Архітектура RAG — self-hosted семантичний пошук

Статус: працює з Phase 71 (2026-06-05). Замінює міст Google NotebookLM, який з'явився у Phase 36.

TL;DR: wiki + issues + skills кожного проєкту Arc вбудовуються (embeddings) у векторний індекс per-project, що живе всередині існуючого SQLite SSOT. Запити повертають семантично релевантні фрагменти з контенту проєкту + спільного глобального пулу skills. Жодного зовнішнього retrieval-сервісу, жодної Google-сесії на користувача, жодних лімітів на джерела.


1. Чому ми пішли з NotebookLM

Міст із Phase 36 обгортав Google NotebookLM як векторне сховище через reverse-engineered cookie auth. Це породило дві проблеми, які базовий продукт виправити не може:

  1. Ліміт джерел. NotebookLM Plus обмежений 100 джерелами на notebook. До середини 2026 кілька проєктів Arc вилетіли за ліміт; логіка витіснення перетворилася на перманентну милицю (#324).
  2. Прив'язка до особистого акаунта. Кожен новий проєкт створював notebook усередині особистого NotebookLM CEO. Масштабування до N клієнтів означало N notebooks у сайдбарі однієї людини — некерований побічний ефект, який міст не міг розв'язати.

Phase 71 замінює retrieval-шар на self-hosted пайплайн, що ніколи не торкається Google-акаунта користувача.


2. Карта компонентів

                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 Модулі коротко

Модуль Роль
shared/embeddings.ts Клієнт Cohere — embedBatch(texts, inputType) для індексації + embedQuery(text) для запитів. Retry з 3× exponential backoff на тимчасових помилках, fail-fast на 401/403.
shared/rag.ts Шар зберігання — upsert / search / removeDoc / removeProject / stats. Володіє чанкером та атомарною транзакцією embeddingsembeddings_vec.
shared/rag-hooks.ts Fire-and-forget обгортки (syncIssue, syncWiki, syncSkill + дзеркальні remove*). Помилки друкуються, але ніколи не відкочують disk/SQL-запис, який їх спричинив.
shared/routes/rag-search.ts Публічна HTTP-точка входу. GET /api/crm/projects/:name/rag/search?q=...
scripts/phase-71-backfill-rag.ts Одноразовий ідемпотентний сідер для існуючого контенту. Прапорці --dry-run / --force / --project / --throttle.

3. Вибір embedding-моделі — Cohere embed-multilingual-v3.0

Властивість Значення
Розмірність 1024-dim float32
Мультимовність Нативна — українська + англійська + 100+ мов ділять спільний підпростір
Вартість ~$0.10 / 1M токенів (Production tier). Оцінка ~$4/місяць на масштабі 50 користувачів за cost model.
Розділення підпросторів input_type="search_document" для індексації vs input_type="search_query" для runtime-запитів. Змішування двох типів обвалює recall.

Live крос-мовний smoke на prod-корпусі:

Запит Мова Top hit 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

UK-запит перевершив відповідну EN-EN дистанцію — вирівнювання мультимовного підпростору реальне, а не маркетингове твердження.


4. Зберігання — sqlite-vec всередині SSOT

Міграція 049_embeddings створює дві таблиці:

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

Такий поділ тримає метадані у звичайному SQLite (можна джойнити з таблицями wiki/issues/skills, прості LIST/DELETE), тоді як віртуальна таблиця vec0 зберігає щільні вектори для ANN-пошуку.

Розмір. 1024-dim float32 = 4096 байтів / рядок. 100K рядків ≈ 400MB. На поточному масштабі ок; переглянути при 1M+ рядків.

Завантаження. sqlite-vec підвантажується як runtime-розширення у shared/db.ts initDb() до запуску міграцій. Міграція 049 робить sanity-check vec_version() і чисто аварійно завершується, якщо розширення не завантажилось.


5. Write path — хуки re-embed (Phase 71.5)

Кожна поверхня запису, що змінює індексований контент, запускає fire-and-forget хук, тож embedding залишається свіжим без окремої sync-задачі.

Тригер Хук Примітки
PUT /api/crm/projects/:name/wiki/save syncWiki(project, path, content) Після успішного Bun.write
POST /api/mcp/issues/:project (створення) syncIssue(project, id, title, body) Після запису в SQLite
PUT /api/mcp/issues/:project/:id (оновлення) syncIssue(project, id, title, body) Те саме
POST /api/mcp/issues/:project/:id/log (активність) немає Лог активності не змінює індексовані title/body — re-embedding палив би квоту даремно
PUT /api/crm/projects/:name/skills/save syncSkill(project, name, content) Per-project skill
DELETE /api/crm/projects/:name/skills/delete removeSkill(project, name) Дзеркало
POST /api/crm/skills (глобальне створення) syncSkill("_global_", name, content) Глобальний пул
PUT /api/crm/skills/:id (глобальне оновлення) syncSkill("_global_", name, content) Лише якщо поле content справді змінилось
DELETE /api/crm/skills/:id (глобальне видалення) removeSkill("_global_", name) Дзеркало
DELETE /api/auth/account (GDPR Art. 17) removeProject(name) на кожен власний проєкт Embeddings не переживають видалення акаунта
Пайплайн транскриптів (Phase 73.6): status → summarized → embedding → done upsert(project, "transcript", id, ragText) через transcript-worker.ts ragText = текст транскрипту + [Xs] frame descriptions + summary tldr/key_points/decisions/action_items. Пропускається при embed_to_rag=0. Не фатально: збій RAG все одно фіналізує задачу у done.

Правило: помилки Cohere друкують один рядок і вмирають. Вони ніколи не відкочують disk/SQL-запис, що їх спричинив. Сталі збої сходяться через нічний backfill, а не через retry у hot path.


6. Read path — search() + фасади для кожної поверхні

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

Під капотом:

  1. Запит вбудовується з input_type="search_query" (інший підпростір, ніж search_document).
  2. KNN по embeddings_vec з overfetch k * 4.
  3. Фільтр по проєкту + опційному doc_type на рівні SQL JOIN.
  4. Повертаються top-K фрагментів, відсортовані за зростанням L2-дистанції.

Публічні фасади


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)

Ідемпотентність. Для кожного кандидата перевіряється наявність (project, doc_type, doc_id) у embeddings; за наявності — пропуск, якщо не встановлено --force. Повторні запуски не коштують квоти Cohere.

Production-запуск (2026-06-05). 814 кандидатів у 20 проєктах + _global_ (37 wiki + 482 issue + 295 skill). Перший прохід спалив місячний Trial-ліміт у 1000 викликів на глобальних skills. Після переходу на Production-ключ решта 178 документів завершилася за 57 с без жодної помилки. Усього записано чанків: ~3 150.


8. Межі безпеки


9. Міграція з NotebookLM (одноразова, 2026-06-05)

Крок Done
API-ключ Cohere + вкладка Platform Settings + live probe #358
Розширення sqlite-vec + міграція 049 #359
Клієнт shared/embeddings.ts #360
Фасад shared/rag.ts #361
Хуки re-embed на записах wiki / issue / skill #362
Backfill-скрипт + prod-запуск #363
Заміна call sites (chat.ts, skills.ts, новий /rag/search, arc kb search) #364
Декомісія services/notebooklm-bridge/ + видалення projects.notebook_id (міграція 050) #365
Документація (ця сторінка + 7 локалізованих заглушок) #366
Soak-валідація #367

10. Операційні нотатки


11. Посилання