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

Статус: работает с Phase 71 (2026-06-05). Заменяет мост Google NotebookLM, выпущенный в Phase 36.

TL;DR: вики + задачи + скиллы каждого проекта Arc эмбеддятся в пер-проектный векторный индекс, живущий внутри существующего SQLite SSOT. Запросы возвращают семантически релевантные пассажи по контенту проекта + общему глобальному пулу скиллов. Никакого внешнего retrieval-сервиса, никакой пер-пользовательской Google-сессии, никаких лимитов на источники.


1. Почему мы ушли с NotebookLM

Мост Phase 36 оборачивал Google NotebookLM как векторное хранилище через reverse-engineered cookie-аутентификацию. Это породило две проблемы, которые сам продукт исправить не может:

  1. Лимит источников. NotebookLM Plus ограничен 100 источниками на ноутбук. К середине 2026 несколько проектов Arc давно превысили лимит; логика вытеснения превратилась в перманентный костыль (#324).
  2. Привязка к личному аккаунту. Каждый новый проект создавал ноутбук внутри личного NotebookLM CEO. Масштабирование до N клиентов означало N ноутбуков в чьём-то одном сайдбаре — неуправляемый побочный эффект, который мост не мог развязать.

Phase 71 заменяет слой ретривала 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) для запросов. Ретраи с 3× экспоненциальным бэкоффом на транзиентных ошибках, 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*). Ошибки печатаются, но никогда не откатывают диск/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. Выбор эмбеддингов — Cohere embed-multilingual-v3.0

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

Живой кросс-языковой смоук по prod-корпусу:

Запрос Язык Топ-хит Дистанция
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 строк ≈ 400МБ. Нормально для текущего масштаба; пересмотреть при 1M+ строк.

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


5. Путь записи — хуки переэмбеддинга (Phase 71.5)

Каждая поверхность записи, мутирующая индексируемый контент, вызывает fire-and-forget хук, чтобы эмбеддинг оставался свежим без отдельной задачи синхронизации.

Триггер Хук Примечания
PUT /api/crm/projects/:name/wiki/save syncWiki(project, path, content) После успешного Bun.write
POST /api/mcp/issues/:project (create) syncIssue(project, id, title, body) После записи в SQLite
PUT /api/mcp/issues/:project/:id (update) syncIssue(project, id, title, body) Аналогично
POST /api/mcp/issues/:project/:id/log (activity) нет Журнал активности не меняет индексируемые title/body — переэмбеддинг жёг бы квоту впустую
PUT /api/crm/projects/:name/skills/save syncSkill(project, name, content) Пер-проектный скилл
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 ст. 17) removeProject(name) для каждого собственного проекта Эмбеддинги не переживают удаление аккаунта
Пайплайн транскриптов (Phase 73.6): status → summarized → embedding → done upsert(project, "transcript", id, ragText) через transcript-worker.ts ragText = текст транскрипта + [Xs] frame descriptions + tldr/key_points/decisions/action_items из саммари. Пропускается при embed_to_rag=0. Не фатально: сбой RAG всё равно финализирует задачу в done.

Правило: ошибки Cohere печатают одну строку и умирают. Они никогда не откатывают диск/SQL-запись, которая их вызвала. Устойчивые сбои сходятся обратно через ночной бэкфилл, а не через ретраи в горячем пути.


6. Путь чтения — 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 с перевыборкой k * 4.
  3. Фильтр по проекту + опциональному doc_type на SQL JOIN.
  4. Возврат top-K пассажей, отсортированных по возрастанию L2-дистанции.

Публичные фасады


7. Бэкфилл — 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 вызовов на глобальных скиллах. После апгрейда до Production-ключа оставшиеся 178 документов завершились за 57 с без ошибок. Всего записано чанков: ~3 150.


8. Границы безопасности


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

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

10. Эксплуатационные заметки


11. Ссылки