diff --git a/docs/backlog/pages-import-broken-400.md b/docs/backlog/pages-import-broken-400.md deleted file mode 100644 index f3975af0..00000000 --- a/docs/backlog/pages-import-broken-400.md +++ /dev/null @@ -1,121 +0,0 @@ -# /pages/import отдаёт 400 «Error processing file content» (регресс) - -Статус: **диагностируемость починена** (fix #1 применён); корневая причина **не -подтверждена** — на текущем коде локально баг воспроизвести не удалось. -Ниже — что удалось выяснить, главный подозреваемый и что проверить дальше. - -## Симптом - -На задеплоенном инстансе эндпоинт `POST /pages/import` отдаёт -`400 BadRequest` с телом «Error processing file content». Раньше работал — -похоже на регресс после редеплоя гитмоста. - -Через этот эндпоинт грузит контент MCP-инструмент `create_page` (это -единственный эндпоинт, принимающий контент при создании страницы — -см. комментарий в `packages/mcp/src/client.ts:961`). - -Что при этом **исправно** (важно для локализации): -- `POST /pages/create` — создание пустой страницы. -- `update_page_json` — запись контента через realtime-коллаборацию (Yjs). - -## Где именно бросается ошибка - -`apps/server/src/integrations/import/services/import.service.ts:93-97` — -`try/catch` вокруг обработки контента: - -```ts -} catch (err) { - const message = 'Error processing file content'; - this.logger.error(message, err); // реальная причина логируется ТОЛЬКО в логи - throw new BadRequestException(message); // наружу уходит generic-строка -} -``` - -Реальный текст ошибки/стек **проглатывается** (наружу — generic-строка), что -нарушает конвенцию проекта (см. CLAUDE.md, «Errors must never be swallowed»). -Поэтому по ответу 400 причину не видно — её надо читать в логах сервера -(`logger.error(message, err)` пишет полный err) ИЛИ воспроизвести локально. - -## Цепочка обработки для .md (что внутри try) - -`importPage` → `processMarkdown(fileContent)`: -1. `markdownToHtml` (`packages/editor-ext/.../marked.utils.ts`) — marked, чистый JS, без DOM. -2. `processHTML`: cheerio `load` → `normalizeImportHtml` (`utils/import-formatter.ts`) — чистый JS. -3. `htmlToJson` (`apps/server/src/collaboration/collaboration.util.ts:118`) → - `generateJSON(html, tiptapExtensions)`. - -## Ключевая зацепка: путь импорта зависит от happy-dom, рабочие пути — нет - -`generateJSON` (`apps/server/src/common/helpers/prosemirror/html/generateJSON.ts`) -парсит HTML через **happy-dom**: `new Window()` + `new localWindow.DOMParser()` + -`parseFromString(...)`, затем `PMDOMParser.fromSchema(schema).parse(doc.body)`. - -А исправные пути DOM-парсер НЕ используют: -- `/pages/create` — пустая страница, контент не парсится. -- `update_page_json` — пишет готовый ProseMirror-JSON в Yjs - (`TiptapTransformer.toYdoc`), без HTML→DOM. - -То есть единственное, что есть в сломанном пути и отсутствует в рабочих, — -**серверный парсинг HTML через happy-dom**. - -## Главный подозреваемый: бамп happy-dom (14 → 20) - -- Изначально было `"happy-dom": "^14.12.3"`. -- Сейчас запинено `"happy-dom": "20.8.9"` в `apps/server/package.json:83` - (+ override в корневом `package.json`). -- Пин на `20.8.9` пришёл в коммите `17da7629 "overrides"` - (Philipinho, 2026-03-28), где `20.8.4` → `20.8.9`. -- Скачок 14 → 20 — это 6 мажоров; у happy-dom между мажорами ломающие - изменения в API `Window`/`DOMParser` и в поведении парсинга HTML. Очень - вероятно, что `generateJSON` ломается на новом happy-dom. - -Версия в node_modules подтверждена: `happy-dom@20.8.9` (симлинк свежий). - -## Второстепенный подозреваемый - -`getSchema(tiptapExtensions)` / `PMDOMParser.parse(...)` могут спотыкаться на -`parseHTML`-правилах недавно добавленных нод (synced blocks/transclusion, -page break, indent, columns, status — все они в `tiptapExtensions`). Но -`getSchema` используется и в рабочем пути (`createYdoc`/`update_page_json`), -поэтому сам по себе билд схемы скорее всего цел — под подозрением именно -DOM-парс-ветка, уникальная для импорта. - -## Направления фикса - -1. **Диагностируемость — ✅ СДЕЛАНО (по конвенции проекта).** В catch-блоках - `import.service.ts` (обработка контента + вставка страницы) реальная - причина теперь прокидывается наружу: `BadRequestException` несёт - `${err.name}: ${err.message}`, а в лог пишется полный `err` со стеком. - Раньше наружу уходила generic-строка "Error processing file content". - Теперь при повторе 400 на проде реальный reason будет виден прямо в теле - ответа — без необходимости лезть в логи. -2. **Корневой фикс — ⏳ НЕ ПОДТВЕРЖДЁН.** Гипотеза happy-dom 14→20 **не - подтвердилась** при локальном воспроизведении на текущем коде (см. ниже). - Применять блайнд-даунгрейд happy-dom нельзя — нужен реальный stack из - логов/ответа после повторения. - -## Локальное воспроизведение (выполнено) - -На текущем `main` (happy-dom 20.8.9) вся цепочка импорта `.md` отработала -без ошибок через `tsx` (импорты прямо из source, не из dist): - -- `markdownToHtml` → cheerio `load` → `normalizeImportHtml` → `generateJSON` - с полным набором из 44 `tiptapExtensions` — **OK** для: - - базового markdown (заголовки, bold/italic, списки, таблицы, code-block, - blockquote) - - edge-cases: пустой контент, whitespace, HTML-сущности, вложенные списки, - task-list, emoji, кириллица, спецсимволы в code, ссылки, изображения, hr -- API happy-dom 20.8.9, используемые в `generateJSON`, существуют и работают: - `new Window()`, `new localWindow.DOMParser()`, `parseFromString('…', - 'text/html')`, `happyDOM.abort()` (async), `happyDOM.close()` (async). -- Блок `finally` в `generateJSON` вызывает `abort()/close()` без `await` и без - `try/catch`, но эти методы не бросают синхронно и не перезаписывают - результат — **не является** причиной 400 (проверено отдельным тестом). -- Все `parseHTML`-правила расширений (status, transclusion, page-break, - columns, subpages и т.д.) участвуют в успешном тесте — ни одно не падает. - -Вывод: на текущем коде баг **не воспроизводится**. Вероятные объяснения — -контент-специфичный кейс, которого нет в тестах; разница между source и -собранным `dist`; либо временное состояние задеплоенного инстанса. После -применения fix #1 повторный 400 покажет реальный reason — по нему и искать -корень. diff --git a/docs/rag-improvements-plan.md b/docs/rag-improvements-plan.md deleted file mode 100644 index fbbb51df..00000000 --- a/docs/rag-improvements-plan.md +++ /dev/null @@ -1,145 +0,0 @@ -# Улучшение качества RAG-поиска агента — план по итерациям - -> Статус: живой документ. Итерация 1 **реализована** (см. ниже). Остальное — -> бэклог на следующие итерации, отсортированный по «качество / усилие». -> Контекст: gitmost — форк Docmost. Семантический поиск агента: per-workspace -> эмбеддинги в `page_embeddings` (pgvector, dimension-agnostic колонка, seq-scan -> с `<=>`), индексация через BullMQ (`reindexPage` / `reindexWorkspace`). -> Активная embedding-модель деплоя: OpenAI `text-embedding-3-large` (3072d). - -## Как сверялось с реальным кодом - -Внешнее предложение по улучшению RAG было сверено с кодовой базой. Точные факты -на момент итерации 1: - -- Хранилище: [page_embeddings](../apps/server/src/database/migrations/20260617T120000-page-embeddings.ts), - колонка `embedding` сделана dimension-agnostic в - [20260617T140000](../apps/server/src/database/migrations/20260617T140000-page-embeddings-dimension-agnostic.ts); - `model_name` / `model_dimensions` хранятся по строке. -- Полнотекстовые индексы **уже существуют** (предложение ошибочно утверждало - обратное): `pages_tsv_idx` на `pages.tsv` и `attachments_tsv_idx`. Конфигурация — - `to_tsvector('english', f_unaccent(...))` + `setweight` - ([тут](../apps/server/src/database/migrations/20250729T213756-add-unaccent-pg_trm-update-tsvector..ts)). -- Чанкинг: `RecursiveCharacterTextSplitter` 1000/200, без префиксов. -- Префиксы `query:` / `passage:` **не нужны**: они требуются для e5/bge/gte/Qwen3, - а деплой на OpenAI `text-embedding-3-large` (этот пункт предложения неприменим). -- Вложения (`attachment_id` в схеме есть) **не индексируются** — индексатор всегда - пишет `attachmentId: null`. - ---- - -## Итерация 1 — РЕАЛИЗОВАНО - -Три «низковисящих фрукта»: - -### 1. Хлебные крошки заголовков в чанках -Файл: [embedding-indexer.service.ts](../apps/server/src/core/ai-chat/embedding/embedding-indexer.service.ts). -Каждый чанк префиксуется путём заголовков `«Заголовок страницы > H1 > H2»` перед -эмбеддингом. Крошки строятся обходом **ProseMirror JSON** (`heading`-ноды с -`attrs.level`), а не markdown-текста — поэтому `#` внутри fenced-код-блока (типичный -bash-сниппет в WirenBoard-вики) **никогда** не принимается за заголовок. Деградация -к старому plain-text чанкингу при отсутствии/сбое `content`. Префикс попадает и в -эмбеддинг, и в `content` (а значит — в лексический индекс `fts` и в сниппет агента). - -### 2. Гибридный поиск (RRF), слияние двух инструментов в один -- Миграция [20260618T150000-page-embeddings-fts.ts](../apps/server/src/database/migrations/20260618T150000-page-embeddings-fts.ts): - генерируемая колонка `fts tsvector GENERATED ALWAYS AS (to_tsvector('english', - f_unaccent(content))) STORED` + GIN-индекс. Конфиг совпадает с `pages.tsv` (та же - обработка unaccent/Cyrillic); `f_unaccent` IMMUTABLE → триггер не нужен. -- Репозиторий: метод `hybridSearch` в - [page-embedding.repo.ts](../apps/server/src/database/repos/ai-chat/page-embedding.repo.ts) — - один SQL-запрос, два CTE (cosine + `websearch_to_tsquery`), слияние Reciprocal Rank - Fusion через FULL OUTER JOIN на уровне чанков. `k=60` (дефолт Cormack 2009 / - ES / OpenSearch / Weaviate), равные веса 1.0/1.0. RRF сливает **ранги**, поэтому - несовместимость шкал BM25 и косинуса не требует нормализации. Dimension-фильтр — - только на семантической стороне. -- Инструменты: `semanticSearch` удалён, `searchPages` стал единым гибридным - инструментом ([ai-chat-tools.service.ts](../apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts)). - Контроль доступа сохранён 1-в-1 (scope по доступным спейсам + пост-фильтр прав - страниц). Если эмбеддинги не настроены / эмбеддинг упал / нет доступных спейсов / - гибрид пуст → graceful fallback на прежний REST-полнотекст (CASL-enforced). - -### 3. Переписывание запроса + описания инструментов -- Описание `searchPages` теперь явно просит агента переформулировать вопрос в - сфокусированный поисковый запрос и переискивать при слабой выдаче (это переживает - кастомный admin-промпт, т.к. лежит в описании инструмента). -- Одна строка-подсказка добавлена в `DEFAULT_PROMPT` - ([ai-chat.prompt.ts](../apps/server/src/core/ai-chat/ai-chat.prompt.ts)). - -> ВАЖНО после деплоя: чтобы крошки и `fts` появились у существующих страниц, нужна -> **переиндексация корпуса** (кнопка «Reindex now» / `WORKSPACE_CREATE_EMBEDDINGS`). -> Миграция заполнит `fts` у текущих строк автоматически, но крошки добавляются только -> при переиндексации (она же перезапишет `content`). - -### Известные нюансы текущей реализации (осознанные компромиссы) -- Гибрид покрывает только проиндексированные чанки. Свежесозданная страница - становится искомой после отработки её BullMQ-`reindexPage`. Пока эмбеддинги не - настроены — работает только REST-fallback (полнотекст уровня страницы по `pages.tsv`). -- Если **весь** пул кандидатов гибрида (до 200 чанков) оказался из закрытых для - пользователя страниц, инструмент вернёт пусто, а не уйдёт в keyword-fallback. - Узкий кейс; возможное улучшение — fallback и при пустом результате пост-фильтра. -- `fts` использует конфиг `english` (как и `pages.tsv`) — без русской стеммизации. - Для русской вики это консистентно с текущим поиском; переход на `simple`/`russian` - конфиг — отдельная задача с переиндексацией. -- `candidates` (=clamp(limit×5, 50, 200)) служит и per-CTE лимитом, и финальным - лимитом слияния; веса RRF равные. Тюнится после появления оценочного харнесса. - ---- - -## Бэклог следующих итераций (по приоритету «качество / усилие») - -### A. Реранкер (cross-encoder) — наибольший ROI после гибрида -Вставить между over-fetch гибрида и дедупом: брать топ-50–100 кандидатов от -`hybridSearch`, реранкать, оставлять топ-5–10. Ожидаемый прирост precision/MRR -+10–25 %. Точка вставки уже готова — это шаг между `hybridSearch(... candidates)` и -циклом дедупа в `searchPages`. -- Хостовый старт (раз уже на OpenAI-инфраструктуре): **Cohere Rerank** или - **Voyage `rerank-2.5`** — провайдер по аналогии с текущим pluggable embedding-конфигом. -- Self-hosted (под Ollama-этос): **BGE-reranker-v2-m3** через HF Text Embeddings - Inference (`/rerank`), либо FlashRank (ONNX/CPU, ~15–30 мс). -- Диагностика: если реранк не двигает метрики — узкое место в recall (чанкинг/гибрид), - а не в ранжировании. - -### B. Индексация вложений — закрыть пробел покрытия -Схема уже готова (`attachment_id`). Добавить в BullMQ-flow шаг извлечения текста из -PDF/документов (PyMuPDF для цифровых PDF; OCR для сканов; для таблиц — markdown через -LLM-парсер) и вливать его в тот же путь чанк→эмбеддинг→`fts`, помечая `attachment_id`. -Структура извлечённых данных важнее голой точности OCR. - -### C. Тюнинг гибрида и оценочный харнесс -- Золотой датасет 30–100 примеров (вопрос → нужная страница/чанк) + Ragas/DeepEval - (Recall@k, MRR/nDCG, context precision/recall, faithfulness). Прогон до/после - каждого изменения. **Прерогатива пропущена в итерации 1 осознанно** — без неё все - нижеследующие тюнинги делаются «на глаз». -- После харнесса: тюнить веса RRF (старт 1.0/1.0), `k` (старт 60), число `candidates`. -- Эксперимент: чанки ~512 симв. против 1000 (предложение указывает на рост precision). - -### D. Contextual Retrieval (Anthropic), если крошек мало -Один LLM-вызов на чанк добавляет предложение-контекст. Снижение провалов выдачи -на 35–49 %. Ложится в BullMQ-`reindexPage`; на сотнях страниц с prompt caching — копейки. -Применять, только если хлебных крошек окажется недостаточно против потери контекста. - -### E. ParadeDB `pg_search` (настоящий BM25), если лексика станет узким местом -Нативный `ts_rank` использует только TF и длину документа, без IDF. `pg_search` -(Rust/Tantivy) даёт честный BM25-индекс. Не drop-in (свои операторы вместо `@@`) — -это изменение кода, а не флаг. На сотнях страниц нативного `tsvector` хватает; брать -только если качество лексического ранжирования упрётся в потолок. - -### F. Прочее -- **Префиксы query/passage** — НЕ нужны на OpenAI. Внедрять только при переходе на - e5/bge/gte/Qwen3 (тогда индексатор ставит `passage:`, запрос — `query:`; BGE-v1.5, - наоборот, префиксов НЕ должна получать). Зафиксировано как ловушка на будущее. -- **Апгрейд embedding-модели** — уже на `text-embedding-3-large` (топ среди закрытых). - Matryoshka (обрезка размерности) — запас на будущее; dimension-agnostic колонка - делает миграцию тривиальной (цена — переэмбеддинг корпуса). -- **HyDE и широкий multi-query/RAG-Fusion** — НЕ рекомендуются как дефолт: в свежих - бенчмарках уступали и добавляют задержку/галлюцинации. - -## Оговорки -- Все внешние числа (62→84 % precision, +17 % Recall@5, −35…49 % провалов, +10–25 % - от реранка) получены на ДРУГИХ корпусах (SEC-отчёты, финтекст, право, медицина). - На этой вики величины будут иными — поэтому пункт C (свой датасет) обязателен перед - тонким тюнингом. Внешние числа — направление, не гарантия величины. -- Часть источников предложения — вендорский маркетинг (Cohere, Voyage, ParadeDB); - направление подтверждается независимыми (T2-RAGBench, оценка Anthropic), но величины - у вендоров могут быть завышены.