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.
179 lines
15 KiB
Markdown
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`
|
|
сохраняется при дефолте).
|