From 19e083596dc84cf372fc3e69d7855ee008f1287e Mon Sep 17 00:00:00 2001 From: "glm5.2 agent 180" Date: Sat, 20 Jun 2026 15:41:54 +0300 Subject: [PATCH] docs: add search morphology + hybrid-search-exposure plans Two feature plans grounded in the current develop: - search-language-morphology-plan.md: make the FTS text-search config selectable (env SEARCH_TS_CONFIG, whitelist english|russian|simple|ru_en; recommend ru_en for the RU/EN wiki). Documents every hardcoded 'english' touchpoint (pages.tsv trigger, page_embeddings.fts generated column, attachments.tsv, search.service.ts, hybrid lexical CTE), the DDL-baked config constraint, reindex strategy, and the regconfig SQL-injection guard. - hybrid-search-general-plan.md: expose the existing pgvector/RRF hybrid search (today agent-only via searchPages) on the user-facing /search, the UI, and the MCP search tool, reusing page-embedding.repo.hybridSearch with identical CASL/permission post-filtering and lexical fallback. --- docs/hybrid-search-general-plan.md | 144 +++++++++++++++++++ docs/search-morphology-language-plan.md | 178 ++++++++++++++++++++++++ 2 files changed, 322 insertions(+) create mode 100644 docs/hybrid-search-general-plan.md create mode 100644 docs/search-morphology-language-plan.md diff --git a/docs/hybrid-search-general-plan.md b/docs/hybrid-search-general-plan.md new file mode 100644 index 00000000..af369801 --- /dev/null +++ b/docs/hybrid-search-general-plan.md @@ -0,0 +1,144 @@ +# Векторный / гибридный поиск в основном поиске (вынос из агента) — план + +> Статус: план (не реализовано). **Важно про текущее состояние:** векторный +> (pgvector) и гибридный (RRF) поиск в форке **уже есть** — но только внутри +> агента. Пользовательский поиск `/search` (а с ним и UI-поиск, и MCP-инструмент +> `search`) всё ещё **чисто лексический**. Эта фича — вынести существующий +> семантический/гибридный движок на общий поисковый поверхностный слой. + +## Как сверялось с реальным кодом (что есть, чего нет) + +**Семантика уже реализована — но только для агента:** +- `page_embeddings` — pgvector, **dimension-agnostic** колонка `embedding`, + `model_name`/`model_dimensions` по строке; per-workspace; индексация через + BullMQ (`reindexPage`/`reindexWorkspace`). Активная модель деплоя — OpenAI + `text-embedding-3-large` (3072d). (См. [rag-improvements-plan.md](./rag-improvements-plan.md).) +- [page-embedding.repo.ts](../apps/server/src/database/repos/ai-chat/page-embedding.repo.ts): + - `searchByEmbedding()` — косинус `<=>` по чанкам (~стр. 143). + - `hybridSearch()` — **RRF-слияние** косинуса и полнотекста (`fts`-CTE на + `websearch_to_tsquery`), `k = 60`, равные веса, scope по workspace + + доступным спейсам, фильтр по совпадающей размерности эмбеддинга (~стр. 211). + - Поиск идёт **seq-scan** по `<=>` (ANN-индекса нет; в комментарии репо прямо + сказано «re-add an HNSW index if [scale grows]»). +- Потребитель — **только** агент: инструмент `searchPages` в + [ai-chat-tools.service.ts](../apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts). + +**Основной поиск — лексический:** +- [search.service.ts](../apps/server/src/core/search/search.service.ts) (`/search`): + только `pages.tsv` + `to_tsquery('english', …)`. Никаких эмбеддингов. +- **MCP-инструмент `search`** дергает именно этот REST: + [packages/mcp/src/client.ts:1818](../packages/mcp/src/client.ts#L1818) → `POST /search`. + Значит, вынеся семантику в `/search`, мы автоматически прокачаем и MCP-поиск. +- `AiService.getEmbeddingModel(workspaceId)` ([ai.service.ts](../apps/server/src/integrations/ai/ai.service.ts)) + умеет строить embedding-модель из per-workspace конфига — то есть всё нужное для + получения вектора запроса уже есть. +- В окружении есть `SEARCH_DRIVER` (`database` | `typesense`). Семантику делаем + как улучшение драйвера `database`, **не** переопределяя `SEARCH_DRIVER`. + +**Вывод:** «добавить векторный поиск» = не писать с нуля, а **переиспользовать +`hybridSearch` в `SearchService`** с тем же контролем доступа, что у лексического +`/search`, + graceful-фолбэк. Это главная мысль плана. + +## Цель + +Дать семантический/гибридный результат на общем поисковом слое (UI-поиск, REST +`/search`, MCP `search`), а не только агенту — чтобы «уволить» находило +«расторжение трудового договора», и чтобы это было доступно вне чата. + +## Архитектура + +### Контракт API +Добавить в `SearchDTO` параметр `mode: 'lexical' | 'semantic' | 'hybrid'`. +- Дефолт — `lexical` (обратная совместимость; тайп-ахед остаётся дешёвым). +- `hybrid`/`semantic` — включается явно (страница полнотекстового поиска, тумблер + в UI, или MCP с `mode:'hybrid'`). + +### Поток в `SearchService.searchPage` для hybrid/semantic +1. Если эмбеддинги настроены (`getEmbeddingModel` не кидает) → эмбеддить **запрос** + (один вызов на поиск). +2. Вызвать `hybridSearch(workspaceId, queryVector, queryText, candidates, accessibleSpaceIds)` + — over-fetch чанков. +3. Чанки → страницы: дедуп по `pageId` (лучший score), маппинг в `SearchResponseDto`. +4. **Контроль доступа 1-в-1 с лексическим путём**: пост-фильтр через + `pagePermissionRepo.filterAccessiblePageIds(...)` (как в текущем `/search`, + стр. 129–139). Scope по доступным спейсам уже внутри `hybridSearch`, но + post-filter по правам страниц обязателен. +5. Highlight — `ts_headline` по `content` чанка (релевантнее, чем по странице). +6. Любой сбой/некочиг (эмбеддинги не настроены, embedding упал, нет доступных + спейсов, гибрид пуст) → **graceful fallback на лексический путь** (тот же + паттерн, что уже использует агентский инструмент). + +### MCP и UI +- MCP: после появления `mode` в `/search` — прокинуть его в + [packages/mcp/src/client.ts](../packages/mcp/src/client.ts) и в схему MCP-тула. + Помнить: `packages/mcp` держит **свою копию** схемы (по `AGENTS.md`). +- UI: тайп-ахед (`searchSuggestions`) остаётся лексическим; семантику включать на + полной странице поиска / тумблером, не на каждое нажатие клавиши. + +## Итерации + +### Итерация 1 (MVP, backend) +`mode` в DTO; ветка hybrid в `SearchService` (эмбеддинг запроса → `hybridSearch` +→ чанк→страница дедуп → пост-фильтр прав → highlight; иначе лексический фолбэк). +Спеки на: паритет прав (закрытая страница не утекает), фолбэк без эмбеддингов, +дедуп страниц. + +### Итерация 2 (MCP + UI) +Прокинуть `mode` в MCP-тул `search` (+ синхронизировать схему-зеркало) и добавить +переключатель/режим на странице поиска клиента. + +### Итерация 3 (производительность и качество) +- Кеш/дебаунс эмбеддингов запроса (не эмбеддить одинаковые запросы повторно). +- ANN-индекс при росте корпуса (см. оговорку про dimension-agnostic ниже). +- Общий оценочный харнес с [rag-improvements-plan.md §C](./rag-improvements-plan.md) + (один золотой датасет на агентский и пользовательский поиск). + +## Точки изменения + +- [search.service.ts](../apps/server/src/core/search/search.service.ts) — ветвление по `mode`, + переиспользование `hybridSearch`, маппинг чанк→страница, общий пост-фильтр прав. +- `search.dto.ts` — поле `mode`. +- [page-embedding.repo.ts](../apps/server/src/database/repos/ai-chat/page-embedding.repo.ts) — + `hybridSearch`/`searchByEmbedding` уже есть; при необходимости — перегрузка, + возвращающая поля под `SearchResponseDto` (без дублирования логики). +- `search.module.ts` — подключить доступ к embedding-модели и репозиторию + эмбеддингов (DI). +- [packages/mcp/src/client.ts](../packages/mcp/src/client.ts) + схема MCP-тула — `mode`. +- Клиент: страница/тумблер поиска (итерация 2). + +## Безопасность и граничные случаи + +- **Паритет прав — риск №1.** Агентский `searchPages` скоупит по доступным + спейсам и пост-фильтрует права; общий поиск **обязан** делать то же + (`filterAccessiblePageIds`), иначе семантика утечёт чанки закрытых страниц. + Покрыть спеком утечки. +- **Путь шар (`shareId`)** в `/search` — анонимный, без per-user скоупа + эмбеддингов. Для шар оставить лексический поиск (или строго ограничить + поддеревом шары); семантику для анонимов в MVP не включать. +- **Стоимость/латентность.** Каждый семантический запрос = 1 вызов embedding-API + (~сотни мс + токены). Поэтому дефолт `lexical`, семантика — по явному режиму, + не на тайп-ахед. +- **Чанк→страница.** Страница может прийти из нескольких чанков — дедуп с лучшим + score; иначе дубликаты в выдаче. +- **Свежие страницы.** Только что созданная/изменённая страница попадёт в + семантику после отработки BullMQ-`reindexPage`. До этого её ловит лексическая + сторона (если есть `fts`-чанк) либо общий лексический фолбэк. Документировать + как осознанный лаг. +- **Фильтр размерности.** `hybridSearch` сравнивает только чанки с + `model_dimensions == dim(query)`. После смены embedding-модели старые чанки + невидимы до переиндексации (свойство уже существующего движка). +- **ANN-индекс vs dimension-agnostic колонка.** Сейчас seq-scan по `<=>` — норм на + масштабе вики. HNSW/IVFFlat требуют фиксированной размерности, а колонка + намеренно dimension-agnostic → ANN потребует либо фиксации размерности, либо + частичных индексов на размерность. Решать при реальном росте, не в MVP. +- **Связь с морфологией.** Лексический CTE гибрида использует `'english'` + (`page_embeddings.fts`). План [search-morphology-language-plan.md](./search-morphology-language-plan.md) + меняет этот конфиг — координировать, чтобы язык был единым в обоих поисках. + +## Оговорки + +- Это **не дубль** [rag-improvements-plan.md](./rag-improvements-plan.md): тот про + качество retrieval агента (реранкер, чанкинг, вложения, харнес). Здесь — про + **поверхность** (вынос уже готового движка в пользовательский/MCP поиск). +- Реранкер из rag-плана (бэклог §A), когда появится, можно переиспользовать и + здесь — точка вставки та же (между over-fetch гибрида и финальным срезом). diff --git a/docs/search-morphology-language-plan.md b/docs/search-morphology-language-plan.md new file mode 100644 index 00000000..2cc32faa --- /dev/null +++ b/docs/search-morphology-language-plan.md @@ -0,0 +1,178 @@ +# Выбор языка морфологии для полнотекстового поиска — план + +> Статус: план (не реализовано). Контекст: gitmost — форк Docmost. Весь +> лексический поиск сейчас жёстко прибит к конфигу `'english'`, из-за чего на +> русской вики не работает стемминг (по запросу «сервер» не находятся +> «серверы / серверов / сервером»). Цель — сделать язык текстового поиска +> конфигурируемым, с разумным дефолтом для русско-английского контента. + +## Как сверялось с реальным кодом + +Все факты ниже проверены по дереву `develop` на момент написания. + +### Где сейчас зашит `'english'` + +**Индексная сторона (DDL — конфиг «запечён» в схему):** + +1. `pages.tsv` — наполняется **триггером** `pages_tsvector_trigger()` + (`BEFORE INSERT OR UPDATE ON pages`). Тело функции: + `setweight(to_tsvector('english', f_unaccent(coalesce(new.title,''))),'A') || setweight(to_tsvector('english', f_unaccent(...text_content...)),'B')`. + Заведено в [20240324T086800-pages-tsvector-trigger.ts](../apps/server/src/database/migrations/20240324T086800-pages-tsvector-trigger.ts), + обновлено под `f_unaccent` в [20250729T213756-add-unaccent-pg_trm-update-tsvector..ts](../apps/server/src/database/migrations/20250729T213756-add-unaccent-pg_trm-update-tsvector..ts). + GIN-индекс — `pages_tsv_idx`. +2. `page_embeddings.fts` — **GENERATED ALWAYS … STORED** колонка: + `to_tsvector('english', f_unaccent(content))`, GIN-индекс `idx_page_embeddings_fts`. + Заведена в [20260618T150000-page-embeddings-fts.ts](../apps/server/src/database/migrations/20260618T150000-page-embeddings-fts.ts). + Это лексическая сторона гибридного (RRF) поиска агента. +3. `attachments.tsv` — колонка `tsvector` + GIN `attachments_tsv_idx` + ([20250901T184612-attachments-search.ts](../apps/server/src/database/migrations/20250901T184612-attachments-search.ts)). + **Путь наполнения этой колонки в коде не локализован** (в миграции триггера + нет) — перед реализацией нужно найти, кто и каким конфигом её пишет, и + привести к тому же языку (или признать колонку неиспользуемой). + +**Сторона запроса (рантайм SQL — должна совпадать с индексом):** + +4. [search.service.ts](../apps/server/src/core/search/search.service.ts) — пользовательский + REST-поиск `/search`. Три вхождения `'english'`: `ts_rank(tsv, to_tsquery('english', …))` + (стр. 50), `ts_headline('english', …)` (стр. 53), `WHERE tsv @@ to_tsquery('english', …)` + (стр. 60). **Через этот же эндпоинт ходит MCP-инструмент `search`** + ([packages/mcp/src/client.ts:1818](../packages/mcp/src/client.ts#L1818) → `POST /search`), + поэтому фикс автоматически чинит и MCP-поиск. +5. [page-embedding.repo.ts](../apps/server/src/database/repos/ai-chat/page-embedding.repo.ts) — + лексический CTE гибридного поиска: `websearch_to_tsquery('english', f_unaccent(queryText))` + (~стр. 252) + `ts_rank(pe.fts, q.query)`. + +### Что уже есть и помогает + +- Расширения `unaccent` и `pg_trgm` **уже установлены** (миграция 20250729T213756), + `f_unaccent(text)` объявлена `IMMUTABLE` (важно: только IMMUTABLE-функцию можно + использовать в выражении GENERATED-колонки и в индексе). +- `searchSuggestions` (тайп-ахед по `users`/`groups`/`pages.title`) работает не + через `tsvector`, а через `ILIKE`/`f_unaccent` (подстрока) — он **уже + языконезависим**, морфология его не касается. Трогать не нужно. +- В окружении уже есть абстракция `SEARCH_DRIVER` (`database` | `typesense`, + дефолт `database`) — см. `environment.service.ts` / `environment.validation.ts`. + Наша задача относится к драйверу `database`. + +## Ключевое ограничение (почему это не «рантайм-переключатель») + +Конфиг текстового поиска у GIN-индекса и у `tsvector`-колонки **запечён в DDL** +(в теле триггера и в выражении GENERATED-колонки). При запросе конфиг в +`to_tsquery(, …)` **обязан совпадать** с тем, которым построен индекс, +иначе токены не сматчатся. Поэтому язык — это **выбор уровня деплоя**, а не +параметр запроса. Сделать конфиг по-настоящему «на строку» (per-workspace) можно, +но дорого: `to_tsvector(regconfig_column, text)` неиммутабельна, значит её +**нельзя** положить в GENERATED-колонку `page_embeddings.fts` (только в +триггер-наполняемую), а запрос по корпусу со смешанными конфигами требует знать +конфиг каждой строки. Это вариант D ниже — откладываем. + +Ещё нюанс выбора конфига: +- `russian` — снежковый стемминг + русские стоп-слова. Минус: режет английские + технические термины и выкидывает русские стоп-слова (по «и»/«в»/«не» не найти). +- `english` (как сейчас) — стеммит по-английски, для русского почти бесполезен. +- `simple` — без стемминга и стоп-слов: только токенизация + lowercase (+ наш + `f_unaccent`). Языконезависим, но нет морфологии («серверы» ≠ «сервер»). +- Технические RU+EN-вики (как WirenBoard) — это **смесь**: лучший охват даёт + объединённый вектор `to_tsvector('russian', x) || to_tsvector('english', x)`. + +## Варианты решения (по возрастанию сложности) + +### Вариант A — глобальный конфиг через env (рекомендуемый механизм) +Ввести `SEARCH_TS_CONFIG` (значения из белого списка: `english` | `russian` | +`simple` | `ru_en`), дефолт `english` (обратная совместимость для текущих +инсталляций). Значение применяется в трёх местах: тело триггера `pages`, +выражение GENERATED-колонки `page_embeddings.fts`, и интерполяция в запросах +(`search.service.ts`, `page-embedding.repo.ts`). +- **Плюсы:** одна понятная ручка; покрывает 95 % кейсов (один язык на инсталляцию). +- **Минусы:** смена значения требует пересборки индексов и переиндексации (см. ниже). + +### Вариант B — объединённый RU+EN вектор (значение `ru_en` варианта A) +В тех же местах генерировать `to_tsvector('russian', f_unaccent(x)) || to_tsvector('english', f_unaccent(x))`, +а на стороне запроса OR-ить два `to_tsquery`. **Рекомендуемый дефолт для этой +русско-английской вики.** +- **Плюсы:** морфология и для русского, и для английского без per-row конфига. +- **Минусы:** ~2× размер `tsvector`, чуть «шумнее» ранжирование (приемлемо на + масштабе вики в сотни–тысячи страниц). + +### Вариант C — `simple` + pg_trgm +`SEARCH_TS_CONFIG=simple` + триграммный фолбэк (`pg_trgm` уже стоит) для нечёткого +совпадения по `title`/`text_content`. +- **Плюсы:** работает на любом языке без выбора; дёшево. +- **Минусы:** нет морфологии; trgm даёт только похожесть подстрок, не словоформы. +Запасной вариант, если не хотим фиксировать язык. + +### Вариант D — per-workspace/per-space `regconfig` +Колонка `search_config regconfig` + триггерное наполнение `pages.tsv`; для +`page_embeddings.fts` пришлось бы заменить GENERATED-колонку на +триггер-наполняемую. Максимум гибкости для мультиязычных инсталляций, максимум +сложности и риска. **Откладываем**, пока не появится реальная мультиязычность. + +## Рекомендация + +Механизм — **вариант A** (env `SEARCH_TS_CONFIG`, белый список, дефолт `english`), +с поддержкой значения **`ru_en`** (вариант B) и рекомендацией ставить именно его +на этой вики. `simple`/`russian` остаются доступными значениями. + +## Точки изменения + +**Backend / DB:** +- Новая миграция (timestamp — позже последней применённой, см. правило + упорядочивания миграций в `AGENTS.md`): + - `CREATE OR REPLACE` функции `pages_tsvector_trigger()` с выбранным конфигом. + - Пересборка существующих строк: одноразовый `UPDATE pages SET title = title` + (перефайр триггера) либо явный `UPDATE pages SET tsv = <новое выражение>`. + - `page_embeddings.fts`: `DROP COLUMN fts` + повторный `ADD COLUMN fts … GENERATED …` + с новым конфигом (GENERATED-колонка пересчитается для всех строк + автоматически; переэмбеддинг **не нужен** — это только текст), пересоздать + `idx_page_embeddings_fts`. + - `attachments.tsv` — привести к тому же конфигу после локализации её писателя. +- `environment.validation.ts` / `environment.service.ts`: добавить `SEARCH_TS_CONFIG` + + геттер `getSearchTsConfig()` с **валидацией по белому списку** (см. безопасность). +- Запросы: [search.service.ts](../apps/server/src/core/search/search.service.ts) (3 места) и + [page-embedding.repo.ts](../apps/server/src/database/repos/ai-chat/page-embedding.repo.ts) + (лексический CTE) — взять конфиг из `EnvironmentService`. Для `ru_en` — OR двух + `to_tsquery`/`websearch_to_tsquery` и `||` двух `to_tsvector`. +- `.env.example` — задокументировать переменную. + +**Frontend:** изменений не требуется (поиск получает результаты как раньше). + +## Безопасность + +Имя `regconfig` **нельзя** интерполировать в SQL как сырую строку из env — это +SQL-инъекция/невалидный конфиг → 500. Разрешать только из **белого списка** +(`english`/`russian`/`simple`/`ru_en`) на уровне геттера; в SQL подставлять уже +сматченное константное имя, а не пользовательский ввод. + +## Граничные случаи и оговорки + +- **Highlight (`ts_headline`) должен использовать тот же конфиг**, что и матч, + иначе подсветка «съедет». Для `ru_en` подсветку проще делать одним конфигом + (`russian`) либо вызывать `ts_headline` по тому конфигу, который дал матч. +- **Стоп-слова `russian`** удаляются из индекса — по ним искать нельзя (компромисс + морфологии). `simple`/`ru_en` это смягчают. +- **Свежесозданные/изменённые страницы**: `pages.tsv` пересчитывается триггером + на каждый write — без проблем. `page_embeddings.fts` пересчитывается при + следующей переиндексации чанков (BullMQ `reindexPage`), но миграция уже + пересоберёт колонку для всех текущих строк. +- **Переиндексация после смены конфига обязательна** (иначе старые `tsv` останутся + в прежнем языке). Для `pages`/`attachments` — в самой миграции; для крошек/ + контента эмбеддингов — кнопка «Reindex now» (см. + [rag-improvements-plan.md](./rag-improvements-plan.md)). +- **Связь с гибридным поиском агента**: меняя конфиг в `page_embeddings.fts` и в + лексическом CTE `page-embedding.repo.ts`, мы меняем и качество RRF-поиска агента + — это согласованное улучшение, но проверить регрессии тестами `ai-chat`. +- **Зависимость с планом «гибридный поиск в основном поиске»** + ([hybrid-search-general-plan.md](./hybrid-search-general-plan.md)): оба плана + трогают `'english'` в лексических запросах. Координировать порядок, чтобы конфиг + везде был единым. + +## Тестирование + +- Интеграционный спек: проиндексировать страницу со словом «серверы», искать + «сервер» → при `russian`/`ru_en` находит, при `english` — нет. +- Смешанный RU+EN документ под `ru_en`: матч и по русской словоформе, и по + английскому термину. +- Проверка whitelist: некорректное значение env → конфигурация падает на старте + (validation), а не уходит в SQL. +- Регрессия MCP `search` и REST `/search` на латинице (поведение `english` + сохраняется при дефолте).