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.
13 KiB
Улучшение качества RAG-поиска агента — план по итерациям
Статус: живой документ. Итерация 1 реализована (см. ниже). Остальное — бэклог на следующие итерации, отсортированный по «качество / усилие». Контекст: gitmost — форк Docmost. Семантический поиск агента: per-workspace эмбеддинги в
page_embeddings(pgvector, dimension-agnostic колонка, seq-scan с<=>), индексация через BullMQ (reindexPage/reindexWorkspace). Активная embedding-модель деплоя: OpenAItext-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(тут). - Чанкинг:
RecursiveCharacterTextSplitter1000/200, без префиксов. - Префиксы
query:/passage:не нужны: они требуются для e5/bge/gte/Qwen3, а деплой на OpenAItext-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_unaccentIMMUTABLE → триггер не нужен. - Репозиторий: метод
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), но величины у вендоров могут быть завышены.