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

13 KiB

Улучшение качества 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, колонка embedding сделана dimension-agnostic в 20260617T140000; model_name / model_dimensions хранятся по строке.
  • Полнотекстовые индексы уже существуют (предложение ошибочно утверждало обратное): pages_tsv_idx на pages.tsv и attachments_tsv_idx. Конфигурация — to_tsvector('english', f_unaccent(...)) + setweight (тут).
  • Чанкинг: RecursiveCharacterTextSplitter 1000/200, без префиксов.
  • Префиксы query: / passage: не нужны: они требуются для e5/bge/gte/Qwen3, а деплой на OpenAI text-embedding-3-large (этот пункт предложения неприменим).
  • Вложения (attachment_id в схеме есть) не индексируются — индексатор всегда пишет attachmentId: null.

Итерация 1 — РЕАЛИЗОВАНО

Три «низковисящих фрукта»:

1. Хлебные крошки заголовков в чанках

Файл: 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: генерируемая колонка 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 — один 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). Контроль доступа сохранён 1-в-1 (scope по доступным спейсам + пост-фильтр прав страниц). Если эмбеддинги не настроены / эмбеддинг упал / нет доступных спейсов / гибрид пуст → graceful fallback на прежний REST-полнотекст (CASL-enforced).

3. Переписывание запроса + описания инструментов

  • Описание searchPages теперь явно просит агента переформулировать вопрос в сфокусированный поисковый запрос и переискивать при слабой выдаче (это переживает кастомный admin-промпт, т.к. лежит в описании инструмента).
  • Одна строка-подсказка добавлена в DEFAULT_PROMPT (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), но величины у вендоров могут быть завышены.