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

15 KiB

Выбор языка морфологии для полнотекстового поиска — план

Статус: план (не реализовано). Контекст: 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, обновлено под f_unaccent в 20250729T213756-add-unaccent-pg_trm-update-tsvector..ts. GIN-индекс — pages_tsv_idx.
  2. page_embeddings.ftsGENERATED ALWAYS … STORED колонка: to_tsvector('english', f_unaccent(content)), GIN-индекс idx_page_embeddings_fts. Заведена в 20260618T150000-page-embeddings-fts.ts. Это лексическая сторона гибридного (RRF) поиска агента.
  3. attachments.tsv — колонка tsvector + GIN attachments_tsv_idx (20250901T184612-attachments-search.ts). Путь наполнения этой колонки в коде не локализован (в миграции триггера нет) — перед реализацией нужно найти, кто и каким конфигом её пишет, и привести к тому же языку (или признать колонку неиспользуемой).

Сторона запроса (рантайм SQL — должна совпадать с индексом):

  1. 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:1818POST /search), поэтому фикс автоматически чинит и MCP-поиск.
  2. 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 (3 места) и 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).
  • Связь с гибридным поиском агента: меняя конфиг в page_embeddings.fts и в лексическом CTE page-embedding.repo.ts, мы меняем и качество RRF-поиска агента — это согласованное улучшение, но проверить регрессии тестами ai-chat.
  • Зависимость с планом «гибридный поиск в основном поиске» (hybrid-search-general-plan.md): оба плана трогают 'english' в лексических запросах. Координировать порядок, чтобы конфиг везде был единым.

Тестирование

  • Интеграционный спек: проиндексировать страницу со словом «серверы», искать «сервер» → при russian/ru_en находит, при english — нет.
  • Смешанный RU+EN документ под ru_en: матч и по русской словоформе, и по английскому термину.
  • Проверка whitelist: некорректное значение env → конфигурация падает на старте (validation), а не уходит в SQL.
  • Регрессия MCP search и REST /search на латинице (поведение english сохраняется при дефолте).