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.
15 KiB
Выбор языка морфологии для полнотекстового поиска — план
Статус: план (не реализовано). Контекст: gitmost — форк Docmost. Весь лексический поиск сейчас жёстко прибит к конфигу
'english', из-за чего на русской вики не работает стемминг (по запросу «сервер» не находятся «серверы / серверов / сервером»). Цель — сделать язык текстового поиска конфигурируемым, с разумным дефолтом для русско-английского контента.
Как сверялось с реальным кодом
Все факты ниже проверены по дереву develop на момент написания.
Где сейчас зашит 'english'
Индексная сторона (DDL — конфиг «запечён» в схему):
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.page_embeddings.fts— GENERATED ALWAYS … STORED колонка:to_tsvector('english', f_unaccent(content)), GIN-индексidx_page_embeddings_fts. Заведена в 20260618T150000-page-embeddings-fts.ts. Это лексическая сторона гибридного (RRF) поиска агента.attachments.tsv— колонкаtsvector+ GINattachments_tsv_idx(20250901T184612-attachments-search.ts). Путь наполнения этой колонки в коде не локализован (в миграции триггера нет) — перед реализацией нужно найти, кто и каким конфигом её пишет, и привести к тому же языку (или признать колонку неиспользуемой).
Сторона запроса (рантайм SQL — должна совпадать с индексом):
- 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 →POST /search), поэтому фикс автоматически чинит и MCP-поиск. - 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пересчитывается при следующей переиндексации чанков (BullMQreindexPage), но миграция уже пересоберёт колонку для всех текущих строк. - Переиндексация после смены конфига обязательна (иначе старые
tsvостанутся в прежнем языке). Дляpages/attachments— в самой миграции; для крошек/ контента эмбеддингов — кнопка «Reindex now» (см. rag-improvements-plan.md). - Связь с гибридным поиском агента: меняя конфиг в
page_embeddings.ftsи в лексическом CTEpage-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сохраняется при дефолте).