Files
gitmost/docs/rag-improvements-plan.md
vvzvlad c8e41e8916 feat(ai): hybrid RRF retrieval, heading-breadcrumb chunks, merged search tool
Improve agent RAG quality with three changes, plus a roadmap doc for the rest.

- Indexer: prefix each chunk with its heading path ("Page > H1 > H2"), built by
  walking the ProseMirror JSON (heading nodes) so a `#` inside a fenced code block
  is never mistaken for a heading. Falls back to plain-text chunking on any error.
  buildChunkRows: drop indexOf-against-source offsets (breadcrumb prefixes break
  verbatim matching) for a cumulative cursor — offsets are provenance-only.
- Hybrid search: new migration adds a generated `fts` tsvector column + GIN index
  to page_embeddings (same english+f_unaccent config as pages.tsv). New
  PageEmbeddingRepo.hybridSearch fuses cosine + full-text rankings via Reciprocal
  Rank Fusion (k=60, equal weights) in one SQL query at chunk granularity.
- Tools: collapse semanticSearch + searchPages into one hybrid `searchPages` tool
  with a query-rewrite-oriented description; gracefully falls back to the REST
  full-text path when embeddings are unconfigured. Access control (space scope +
  page-permission post-filter) preserved. Add a query-rewrite hint to the default
  system prompt.
- docs/rag-improvements-plan.md: record what shipped and the deferred backlog
  (reranker, attachment indexing, eval harness, tuning).

Note: requires a corpus reindex to populate breadcrumbs on existing pages.
2026-06-18 03:43:01 +03:00

146 lines
13 KiB
Markdown

# Улучшение качества 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), но величины
у вендоров могут быть завышены.