Архітектура 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. Це породило дві проблеми, які базовий продукт виправити не може:
- Ліміт джерел. NotebookLM Plus обмежений 100 джерелами на notebook. До середини 2026 кілька проєктів Arc вилетіли за ліміт; логіка витіснення перетворилася на перманентну милицю (
#324). - Прив'язка до особистого акаунта. Кожен новий проєкт створював 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. Володіє чанкером та атомарною транзакцією embeddings↔embeddings_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
Під капотом:
- Запит вбудовується з
input_type="search_query"(інший підпростір, ніжsearch_document). - KNN по
embeddings_vecз overfetchk * 4. - Фільтр по проєкту + опційному
doc_typeна рівні SQL JOIN. - Повертаються top-K фрагментів, відсортовані за зростанням L2-дистанції.
Публічні фасади
GET /api/crm/projects/:name/rag/search?q=...&k=...&include_global=true&doc_types=...— HTTP-точка входу. Живитьarc kb search+ інструментask_notebooklmу чаті.chat.ts executeAskNotebooklm— виконує пошук по проєкту + глобальний паралельно, зливає за дистанцією, відкочується до keyword-пошуку, коли top hitd > 1.6або нуль результатів.skills.ts handleGenerateSkill— використовує RAG для retrieval house-style, потім викликає Claude Sonnet для генерації.help.ts ragSemantic— чат Arc Help подає проєктні + глобальні збіги як контекст моделі.
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. Межі безпеки
- API-ключ Cohere живе у vault (
COHERE_API_KEY), ротується через Platform Settings → RAG / Semantic search. Жива кнопкаTestвиконує 1-токенний embed-виклик для перевірки доступності. - Жодного витоку промптів у Cohere. Embeddings — це похідні bag-of-words; відновити з них вихідний текст практично неможливо. Власна політика Cohere каже, що вони не тренуються на API-трафіку.
- Multi-tenancy. Імена проєктів — це ключі неймспейсів;
canAccessProjectохороняє/api/crm/projects/:name/*до запуску пошукового хендлера. Глобальні skills живуть у sentinel-неймспейсі_global_, з яким не може колізувати жодне ім'я проєкту користувача (валідатор забороняє імена, що починаються з_). - GDPR Art. 17.
DELETE /api/auth/accountкаскадно викликаєrag.removeProjectдля кожного власного проєкту, тож embeddings не переживають видалення.
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. Операційні нотатки
- Ім'я інструмента залишилось
ask_notebooklm. Tool definition Anthropic їде далі до Claude у розмові Cloud PM. Перейменування зламало б in-flight циклиtool_use. Реалізація тепер чистий RAG; публічний контракт залишився стабільним. - Audio overview зник. Автогенеровані аудіопідсумки NotebookLM не мають еквівалента у self-hosted пайплайні.
POST /api/crm/projects/:name/memory/fetch-artifactтепер повертає410 Gone. Якщо це знадобиться знову — це окрема фаза (імовірно Whisper TTS прохід по wiki проєкту). - Кнопка Re-index досі працює.
POST /api/crm/projects/:name/memory/refreshтепер повторно вбудовуєMANIFEST.md+ROADMAP.md+ ключові файли у локальне сховищеembeddings, а не завантажує у NotebookLM. Для користувача: та сама кнопка, той самий результат ("project knowledge is now fresh"), інше сховище. - Жодних notebook URLs.
GET /api/crm/projects/:name/notebooksповертає{ notebooks: [], retired: "phase-71.8" }, щоб старі білди frontend не падали з 404. Вкладку Neural Memory у CRM приберуть у наступному косметичному проході.
11. Посилання
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- Issues #321 (батьківська) · #358–#367 (підфази) · #322 / #324 (закриті разом)
- Лог рішень:
docs/architecture/PHASE_71_RAG_MIGRATION.md