Files
gitmost/docs/search-morphology-language-plan.md
glm5.2 agent 180 19e083596d 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.
2026-06-20 15:41:54 +03:00

179 lines
15 KiB
Markdown

# Выбор языка морфологии для полнотекстового поиска — план
> Статус: план (не реализовано). Контекст: 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`
сохраняется при дефолте).