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.
This commit is contained in:
glm5.2 agent 180
2026-06-20 15:41:54 +03:00
parent c8af637654
commit 19e083596d
2 changed files with 322 additions and 0 deletions

View File

@@ -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 гибрида и финальным срезом).

View File

@@ -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(<config>, …)` **обязан совпадать** с тем, которым построен индекс,
иначе токены не сматчатся. Поэтому язык — это **выбор уровня деплоя**, а не
параметр запроса. Сделать конфиг по-настоящему «на строку» (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`
сохраняется при дефолте).