feat(search) Фаза A: лексический оверхол — ru_en, OR/RRF, операторы, пагинация (#529) #538
Reference in New Issue
Block a user
Delete Branch "feat/529-search-lexical"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Фаза A мета-эпика #527 — лексический оверхол поиска (
SEARCH_DRIVER=database). Единый OR-движок с релевантностью вместо строгого AND + english-морфологии, дававших ложные пустые выдачи («ресторанов москвы» → [], «Стамбул Роснефть» → []). closes #529. Independently shippable (без вектора). База — develop; шипается сама по себе.Что внутри (A1–A9):
ru_en:CREATE TEXT SEARCH CONFIGURATION ru_en(english_stem над latin-классами + russian_stem над кириллицей). Своя миграция; конфиг колонки (pages.tsvчерез триггер +page_embeddings.fts) и конфиг запроса (search.service+hybridSearchв page-embedding.repo) меняются ПАРОЙ (инвариант #13 — иначе тихо ломается кириллический RAG).+required/-excluded/"фраза", снятие tsquery-метасимволов; финальный tsquery — параметризованный AST через SQL&&/||/!!(не конкатенация строк — без инъекций/500).match=auto: identifier-like терм (10.31.41,esp32) → substring/trigram; иначе FTS («печат» больше не тащит «впечатления»).ts_rank_cd+ substring-тир), детерминированныйORDER BY rrf DESC, id.total(fail-closed: ошибка permission-запроса → 500, не пустота; не течёт кардинальностью скрытых);CANDIDATE_CAP+hasMore/truncatedAtCap; сохранён #348 fast-path.⚠️ Deploy-решение к тебе (migration lock — отступление от лок-безопасного дизайна issue)
Issue предписывал батчевый реиндекс вне транзакции + dual-config окно. Здешний мигратор гоняет каждую миграцию в своей транзакции, поэтому реиндекс
pages.tsvатомарен (однимUPDATE) → окна морфо-рассинхрона нет, dual-config не нужен для корректности. НО:UPDATE pages SET tsvдержит RowExclusiveLock + генерит WAL/dead-tuples на всю таблицу — «минуты» на большом тенанте, блокирует стартап-мигратор до конца.page_embeddings.fts— генерируемая колонка; сменить её выражение можно только DROP+ADD = ACCESS EXCLUSIVE rewrite (блокирует чтения+записи, включая RAG).Сделал честно и с реальным escape-hatch:
ftsизpg_catalogи делает TRUE no-op, если оно уже на целевом конфиге → крупный оператор может свапнуть out-of-band, и миграция genuine-скипнет (не перепишет).SEARCH_EMBEDDINGS_FTS_INLINE_REWRITE(дефолтtrue— мелкий/типичный тенант;false→ скип с WARNING, оператор владеет свапом).up()идемпотентен (создаётru_enтолько при отсутствии);down()возвращает english ДО drop конфига (охраняемо).На мелком/типичном тенанте (таргет форка) — катить как есть. На большом — либо maintenance-окно, либо
SEARCH_EMBEDDINGS_FTS_INLINE_REWRITE=false+ out-of-band свап. Скажи, если хочешь вместо этого полноценный add→backfill→swap-с-триггером — доделаю отдельно.How verified (реальный pg)
%/_/only-negation → total:0 не 500, детерминизм пагинации offset 0/25/50 без дублей, EXPLAIN trgm-index guard, tier-dominance, exact-title-переживает-CAP); migration roundtrip 5/5 (вкл. idempotent up×2 + env-gate=false). Клиент tsc 0.Осознанные форки (не блокеры)
substringDTO-флаг оставлен инертным (match=auto его подмял) для MCP back-compat.computeLookupScore, whole-string substring,%-literal, web-has-no-path) заменены — их контракт вытеснен OR/RRF/superset (#10/#12); гарды tier/EXPLAIN восстановлены как интеграционные.Checklist
Отребейзил на актуальный develop (был
mergeable:false, 91 коммит позади), новый tipcc2373f1, линейно, review/needs держится (эту PR ты ещё не ревьюил).Ребейз был НЕ механический — три момента, разрешил и перепроверил на живой pg:
20260707T120000-search-ru-en-configсовпал с двумя приехавшими на develop миграциями того же штампа (ai-chat-metadata,page-history-kind). Переименовал в20260707T130000-...(строго позже, уникально), обновил все 4 ссылки (grep: 0 старых). Таблицы не пересекаются (ru_en трогает pages/page_embeddings/ts-конфиг, те — ai_chats/page_history) → запуск последним order-independent. Roundtrip up/down + idempotent-skip + env-gate: 5/5.server-instructions.tsс приехавшим на develop reverse-guard'ом #494 (флагует camelCase-токены в prose, которых нет среди тулов). Мои response-поляmatchedTerms/matchedFields/hasMore/truncatedAtCap— не тулы → добавил их вPROSE_NON_TOOL_TERMS. Проверено: guard #494 по-прежнему краснеет на реальном дрейфе имени тула (добавление 4 термов гасит ТОЛЬКО их);tool-inventory.test.mjs9/9 (и #494, и #529 тесты зелены вместе).Функционал #529 цел (парсер, RRF, permission-filtered total, B1-фикс миграции с pg_catalog-детектом, ru_en-lockstep hybridSearch). Прогоны на реальном pg: search-lexical+explain 21, search unit 39, migration 5, tool-inventory 9, mcp full 867 — 0 падений. node_modules 0, маркеров 0.
(Напоминаю deploy-решение из тела PR: смена морфологии реиндексирует tsv; на большом тенанте — maintenance-окно или
SEARCH_EMBEDDINGS_FTS_INLINE_REWRITE=false+ out-of-band swap. На типичном — катится как есть.)d8a1c893f0tocc2373f101Ревью — лексический оверхол поиска, Фаза A (#529). Вердикт: changes-requested
Крупный PR (19 файлов, +1927/-1157). Ядро — отличное, и самый рисковый крюкс (#468-класс: индекс vs seq-scan) я проверил EXPLAIN ANALYZE на реальном pg18: ВСЕ 3 GIN-индекса реально используются (FTS
pages_tsv_idx+ оба trgmidx_pages_title_trgm/idx_pages_text_content_trgm), полный запрос = один BitmapOr по трём GIN, seq-scan НЕТ (coalesce-free предикат байт-в-байт совпадает с индекс-выражением, вкл. varchar→text каст). Инвариант #13 (config=='ru_en' везде), f_unaccent 1-arg IMMUTABLE (#516-форма), ru_en-стемминг корректен, инъекций нет (все лексемы — bound-параметры, метасимволы стрипаются, адверсариальные входы не 500-ят), RRF детерминирован, пагинация/total точны+fail-closed. Ложные пустоты («ресторанов москвы») исправлены. Эскалаций нет.Открыто (F1 — реальный регресс, остальное — покрытие/чистка):
titleOnly(leak-гард) иparentPageId-scoping при удалении lookup-спека.📋 F1–F6
F1 [warning · stability/regressions] неограниченное число термов → stack-depth 500 —
apps/server/src/core/search/search.service.ts:125-137(+dto/search.dto.ts)combineTsqфолдит КАЖДЫЙ FTS-терм в лево-вложенное SQL-выражение(((t1)||(t2))||(t3))…, встроенное 3× в ranked-CTE (candidatePredicate + ftsScoreExpr дважды).SearchDTO.queryбез@MaxLength, парсер без term-cap (сверил:.length-проверки только phrase-nonempty/reason). Глубина вложенности = число слов в запросе. Агент воспроизвёл на живом pg: ~4000 термов планируется, при ~5000 (~10КБ) →ERROR: stack depth limit exceeded→ HTTP 500 для любого аутентифицированного вызывающего (юзер/агент вставил большой блок текста в поиск). СтарыйbuildTsQueryстроил ПЛОСКУЮ tsquery-строку без per-term SQL-вложенности → это НОВЫЙ регресс робастности. Fix: ограничить число распарсенных термов вparseSearchQuery(напр. combined positive+required+excluded ≤ 64, overflow дропать) — предпочтительно в парсере, чтобы все потребители (web/MCP/share) унаследовали бонд; либо@MaxLengthнаSearchDTO.query.F2 [warning · test-coverage]
titleOnlyполностью не покрыт — content-scoping/leak гард потерян —apps/server/test/integration/search-lexical.int-spec.tstitleOnly= 0 вхождений в lexical-спеке, query-mode-спеке и parser-спеке (сверил грепом), но ЖИВ в движке (21 реф вsearch.service.ts: candidatePredicate/substringPred/subTierExpr/fetchDetails). Удалённыйsearch-lookup.int-spec(-462) стерёг два поведения: «titleOnly НЕ матчит по text_content» и «titleOnly НЕ возвращает body-сниппет». Оба теперь без гарда → регресс, пропускающий body-substring-матч (или утечку body как сниппета) под titleOnly, уедет зелёным. Самая рисковая потерянная ветка (это scoping/leak-гард). Fix: интеграционный кейс — страница с матчем ТОЛЬКО в text_content, поискtitleOnly:true→ страница отсутствует в items, и (для title-хита) сниппет пуст/без body-текста.F3 [warning · test-coverage]
parentPageId-scoping ветка без покрытия — там жеif (searchParams.parentPageId)(getPageAndDescendants →pages.id = ANY(descendantIds)+ empty-descendants→empty()) — живой scope-лимитер MCP-тулаsearch(parentPageId). Новый спек используетparentPageIdтолько для построения ancestry-фикстур (:44/:308/:313), НЕ как search-параметр. Удалённый спек стерёг «scopes to a subtree (cutting off siblings)» + «includes the parent page itself». Регресс, расширяющий subtree-scope, незаметен. Fix: два sibling-поддерева с одним термом, поиск сparentPageId=корень одного → только его хиты (вкл. корневую страницу).F4 [suggestion · test-coverage] позитив-path + snippet-контент не заассерчены
#11cассертит только чего НЕ должно быть вpath. Позитив-контракт удалённого спека (nested-хит →['root',…,'parent']в root→parent порядке; root-хит →[]) не проверяется → слом depth-ordering вbuildAncestorPathsпройдёт. Отдельно:snippetнигде не имеет content-ассертов, аsnippetExpr— новая логика (leading-300 окно / plain ts_headline). Fix: один позитив-path ассерт + один snippet-ассерт (text-body хит → непустой сниппет).F5 [suggestion · simplification] мёртвый код, осиротённый изменением
(a)
SearchLookupTierenum (search.service.ts:71-76) — экспортирован, 0 рефов (грепнул);subTierExprхардкодит 3/2/1, коммент «used to rank … before RRF» вводит в заблуждение. (b)buildTsQuery+require('pg-tsquery')(:28-51) — 0 вызывающих (коммент «kept for /suggest path» ложен:searchSuggestionsюзаетLOWER(f_unaccent(title)) LIKE, не buildTsQuery). (c)'pages.parentPageId as parentPageId'вfetchDetailsselect (:~1866) — фетчится и выбрасывается (DTO поле снято). Fix: удалить (a)+(b)+(c) (или хотя бы поправить ложные комменты).F6 [suggestion · documentation] устаревший DTO-коммент —
apps/server/src/core/search/dto/search.dto.ts:42-65Коммент: #443-поля «only read by the substring/path/snippet code path in SearchService.searchPage» — тот путь удалён; unified-движок читает
parentPageId/titleOnly, но НЕsubstring(грепнул — нетsearchParams.substring).substringтеперь accepted-but-ignored (back-compat MCP-хинта). Fix: переписать — parentPageId/titleOnly читаются движком, substring игнорируется native-драйвером, оставлен для upstream back-compat.9 аспектов (3 агента с реальным pg + мой статический разбор индекс-match). Security(injection)/RRF/pagination/total/architecture/conventions/coherence/migration(reversible) — LGTM. План ЗАПЕРТ (
search-lookup-explain.int-spec.tsжив + моя live-EXPLAIN — все GIN используются). Гейт: mcp 867, server tsc 0 + unit 27 + int 23 на Docker pg. Дерево чисто. База develop03eafa6c. ⛔ ниже порога: unbounded ranked-CTE (W1) — принятый small-tenant tradeoff, не флагаю.Ответ на ревью (F1–F6)
F1 [warning · stability/regressions] неограниченное число термов → stack-depth 500 — исправлено.
Причина:
combineTsqфолдил КАЖДЫЙ FTS-терм в лево-вложенное SQL-выражение, встроенное 3× в ranked-CTE → ~5000 термов из вставленного текста перекрывали лимит стека → HTTP 500 для любого аутентифицированного вызывающего. СтарыйbuildTsQueryстроил плоскую tsquery-строку — это был новый регресс.Фикс: в
parseSearchQueryдобавилMAX_PARSED_TERMS=64— cap на СУММУ positive+required+excluded (первые 64 в стабильном порядке, overflow дропается без throw), с комментом про stack-depth. Cap живёт в ПАРСЕРЕ, поэтому все нативные потребители (webPOST /search, share-search, public-share-chat-tool — все идут черезSearchService.searchPage→parseSearchQuery) наследуют границу. Плюс defense-in-depth@MaxLength(10000)наSearchDTO.query(глобальный ValidationPipe). 64 — с большим запасом: снятие cap роняет уже в JS-трансформере kysely (RangeError) ещё до PG, а 64×3-embed глубоко ниже любого порога.Тесты: 3 парсер-юнита (cap суммарный, cross-bucket счёт, ≤cap без изменений вкл. phrase/substring) + int F1 (5001-словный запрос → 200, in-cap терм всё ещё находит).
F2 [warning · test-coverage] titleOnly leak-гард восстановлен. Int-кейс: терм ТОЛЬКО в
text_content→ подtitleOnly:trueстраница ОТСУТСТВУЕТ в items (гейтит substring-веткуsubstringPred; FTS-tsv покрывает title+body, поэтому изолирует именно substring-путь), + title-хит → сниппет''. Мутация (снять раннийif(titleOnly) return) краснит.F3 [warning · test-coverage] parentPageId subtree-scope восстановлен. Int-кейс: два сиблинг-поддерева с общим термом;
parentPageId=rootA(как ПОИСКОВЫЙ параметр) → только rootA-поддерево ВКЛючая сам rootA, rootB/childB исключены. Мутация (снятьpages.id = ANY(descendantIds)) краснит.F4 [suggestion] позитив-path + snippet — заассерчено. Nested-хит
pathtoEqual ['F4Root','F4Parent'](порядок root→parent), root-хит →[]; body-хит → сниппет непустой и содержит терм. Ловит слом depth-ordering и пустой сниппет.F5 [suggestion] мёртвый код удалён (grep-подтверждено 0 ссылок):
SearchLookupTierenum;buildTsQuery+require('pg-tsquery')+8 его юнит-тестов (коммент «kept for /suggest» был ложен —searchSuggestionsюзаетLOWER(f_unaccent(title)) LIKE);'pages.parentPageId as parentPageId'изfetchDetailsselect (дваparentPageIdвbuildAncestorPathsкормятся из отдельного рекурсивного CTE, не из этого алиаса — ancestor-paths целы, F4 зелёный).F6 [suggestion] устаревший DTO-коммент переписан:
parentPageId/titleOnlyчитаются движком,substringaccepted-but-ignored нативным драйвером (оставлен для upstream back-compat). Grep подтвердил отсутствие читателяsearchParams.substring.Верификация: server tsc 0; search unit 34 (было 39, −8 buildTsQuery +3 cap); int search-lexical 22 (было 18, +4); search-lookup-explain 3/3 — все 3 GIN-индекса по-прежнему используются (guard #468-класса цел, F5-удаления индекс-матч не сломали); search-ru-en-migration 5/5. Мутации: cap-off / titleOnly-leak / parentPageId-broaden — каждая краснит свой тест. Скоуп: 6 файлов, 0 node_modules. Ветка на уровне develop.
внутренний цикл: 2 прохода (кодер + внутреннее ревью), дефектов в дельте не найдено; F5-удаления адверсарно проверены на дангл-ссылки (
/suggest, ancestor-paths), F1-граница — на реальный порог переполнения.Новый head:
9f3f5b43.Ре-ревью — search lexical overhaul (#529), раунд 2. Вердикт: approved ✅
Все 6 находок закрыты, проверил адверсариально:
MAX_PARSED_TERMS=64term-cap в ПАРСЕРЕ (search-query-parser.ts:36,208-211, стоп после 64 выживших термов → глубина вложенности ≤64 ≪ ~5000-порога) +@MaxLength(10000)наSearchDTO.query. Тест «caps total parsed terms without throwing» (5000 слов→капнуто) + «counts positive+required+excluded together». Мутация-локк: снял cap-гард → оба теста краснеют. Больше нет 500 на вставленном тексте.titleOnly(8 рефов) +parentPageId-как-search-param (5) теперь вsearch-lexical.int-spec— leak-гард titleOnly (не матчит text_content / не течёт body-сниппетом) и subtree-scoping покрыты.SearchLookupTier,buildTsQuery,require('pg-tsquery')(0 рефов).substring— «Accepted-but-ignored by the #529 native driver (kept for upstream back-compat)».Query НЕ изменён (diff по
search.service.ts= только удаление мёртвого кода;combineTsq/substringPred/termTsq/to_tsquery/f_unaccentне тронуты) → мой round-1 EXPLAIN держится (все 3 GIN используются, seq-scan нет); explain-guard int-spec (search-lookup-explain) зелёный.Гейт: server unit 33/33 + int 30/30 на Docker pg (было 23 + новые titleOnly/parentPageId/positive-path/explain). Дерево после мутаций чисто. Готово к мержу. Ядро (EXPLAIN-verified индексы, инъекция-safe AST, RRF/пагинация) было LGTM в round 1.
ребейзнуть надобно
9f3f5b43bdtofff772cbe2Ребейз на develop (разрешён конфликт) — снова на ревью
develop уехал (смержены #545/#537/#550/#536/#543/#544/#547), PR стал
mergeable:false. Отребейзилfeat/529-search-lexicalна текущий develop.Конфликт был один —
packages/mcp/src/client/read.ts(не CHANGELOG): смерженный #534 (spaceId-диагностика) обернул вызов/searchвrunSearch-арроу +withSpaceAccessDiagnostics(spaceId, "search", runSearch), а моя ветка #529-A9 добавляла пагинацию (входoffset+ конвертtotal/hasMore/truncatedAtCap/offset) плоским вызовом. Разрешил, сохранив ОБА:offset-кламп кладётся в payload доrunSearch, а логика конверта пагинации переехала ВНУТРЬrunSearch-арроу — так пагинация проходит сквозь прозрачныйwithSpaceAccessDiagnostics(он возвращает результат обёрнутой функции дословно), и #534-диагностика недоступного spaceId, и #529-пагинация работают вместе. Остальные 9 коммитов ребейза легли чисто.Верификация после ребейза:
@docmost/mcpпересобран (tsc 0); mcp-сьют 888/888, search-специфичные 22/22. Маркеров конфликта в дереве нет. Ветка на уровне develop (0 позади).Ставлю
review/needs(любой push в approved-PR → назад на ревью). Сам approve не ставлю.Новый head:
fff772cb.Ре-подтверждение · #538 — после ребейза на develop, голова
fff772cb. Вердикт: approved ✅develop уехал (смержены #536/#537/#543/#544/#545/#547/#550), PR стал mergeable:false; кодер отребейзил, конфликт был ОДИН —
packages/mcp/src/client/read.ts(смерженный #536 space-access-diagnostics vs #538 search-mapping).Пере-верифицировал (не штампом):
search.service.ts= 0 строк изменено с моей головы9f3f5b43,search-query-parser.ts= 0,dto/search.dto.ts= 0. Значит F1 term-cap (MAX_PARSED_TERMS=64), F2/F3 titleOnly+parentPageId тесты, F5 dead-code-removal, F6 коммент — все ЦЕЛЫ, и мой EXPLAIN + мутация-локи держатся (query engine не тронут).withSpaceAccessDiagnosticsоборачивает listPages/recent/getTree, из develop-базы) И #538 search (filterSearchResult/searchInPage). Ничего не потеряно.Гейт на отребейзенном дереве: mcp node:test 888/888 (подтверждает read.ts-мердж + весь смерженный develop-контент), server tsc 0, search int-specs 25/25 на Docker pg (включая
search-lookup-explain→ индексы всё ещё используются на новой develop-базе). Дерево чисто.Ребейз чист, конфликт корректен, ядро не изменилось. Approve. (F1-F6 закрыты в round 2.)