feat(search): агентский lookup-режим — substring по точкам/дефисам/цифрам, path, snippet, scope (#443, часть 1/3)
MCP-сервером пользуются LLM-агенты; основной паттерн — lookup: найти страницу по обрывку технической строки и сразу понять, где она лежит и что в ней. Раньше на такой вопрос уходило 3–5 вызовов. Эта часть закрывает `search` (часть 1 из 3; get_tree и get_page_context — следующими PR стопкой). Только чтение. Серверная часть — opt-in, веб-UI байт-в-байт неизменен: - SearchDTO: опциональные substring/parentPageId/titleOnly (default-off; без флагов путь FTS не тронут — guard `if (substring) return searchPageLookup`). - searchPageLookup: один скан pages, WHERE = title LIKE '%q%' OR (если не titleOnly) text LIKE '%q%' OR (если tsquery непустой) tsv @@ ... . LIKE- метасимволы %/_/\ экранируются (escapeLikePattern, ESCAPE '\') — `%`/`_` не матчат всё; substring-ветка работает даже при пустом tsquery (кейс 10.0.12). - Ранжирование тирами (TITLE_EXACT > TITLE_SUBSTRING > TEXT), вторичный сигнал ts_rank / позиция; score∈(0,1] только для сортировки одной выдачи (формула в комментарии). 200-cap упорядочен по SQL-прокси тира ДО среза (иначе Postgres отдаёт произвольные 200 и сильный хит мог выпасть). Пермишен-фильтр к merged-набору ДО limit. path — одна рекурсивная CTE на все хиты (не N+1). - snippet оконный в SQL (~500 символов вокруг первого совпадения). Позиция и срез в ОДНОМ пространстве LOWER(f_unaccent(...)) — f_unaccent не length- preserving (ß→ss, лигатуры, …→...), иначе окно смещалось/пустело. titleOnly → пустой snippet. Компромисс задокументирован. - Миграция: GIN gin_trgm_ops по LOWER(f_unaccent(text_content)); title-trgm индекс #348 переиспользован (IF NOT EXISTS), down() дропает только новый. MCP: схема search (spaceId/parentPageId/titleOnly/limit 1–50, default 10), client.search прокидывает substring:true, filterSearchResult → {pageId, title, path, snippet, score}. Инвариант: наружу только pageId (UUID), slugId/id никогда. Комментарии про намеренное расхождение с in-app hybrid-RRF (не тронут) и про деградацию на stock-upstream/Typesense (substring→plain FTS, без path/ snippet). Проверка на реальном Postgres: server integration 16/16 (вся acceptance-таблица #443 + регрессии на смещение snippet и cap-200), server unit 27/27, mcp node --test 708/708, tsc чисто. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
/**
|
||||
* #443 — trigram indexes for the opt-in agent-lookup search mode.
|
||||
*
|
||||
* The lookup mode adds a substring branch that runs leading-wildcard
|
||||
* `LOWER(f_unaccent(col)) LIKE '%q%'` predicates on pages.title and
|
||||
* pages.text_content. A leading wildcard cannot use a b-tree index, so without a
|
||||
* GIN trigram index each such predicate is a sequential scan.
|
||||
*
|
||||
* - TITLE: the title predicate is IDENTICAL to the one added for /search/suggest
|
||||
* (#348), which already created `idx_pages_title_trgm` on
|
||||
* `(LOWER(f_unaccent(title))) gin_trgm_ops`. We re-assert it here with
|
||||
* `IF NOT EXISTS` so a fresh DB that somehow lacks it still gets it, and so
|
||||
* this migration is self-describing. On an existing DB it is a no-op.
|
||||
*
|
||||
* - TEXT_CONTENT: NEW. The substring branch scans text_content when the query
|
||||
* is not titleOnly. text_content is the large column, so a GIN trigram index
|
||||
* on it is the meaningful acceleration for the lookup mode. The lookup search
|
||||
* is ALWAYS space-scoped (spaceId or the user's member spaces), so on small
|
||||
* instances a per-space sequential scan is tolerable — but the index turns the
|
||||
* `%q%` text predicate into a Bitmap Index Scan and removes the only
|
||||
* unbounded-per-space cost of the feature. We add it. The trade-off is disk +
|
||||
* write amplification on page edits (GIN trigram indexes are larger and slower
|
||||
* to update than b-trees); on the small instances this fork targets that cost
|
||||
* is acceptable and the read win on agent lookups is the priority.
|
||||
*
|
||||
* DEPLOY-TIME LOCK WARNING: plain (non-CONCURRENT) CREATE INDEX — Kysely runs
|
||||
* each migration in a transaction, so CONCURRENTLY is impossible. The build takes
|
||||
* a SHARE lock that BLOCKS writes on `pages` for its duration. The text_content
|
||||
* GIN build is the slow one and can take minutes on a large tenant. For large
|
||||
* installations, run this in a maintenance window or build the index out-of-band
|
||||
* with CREATE INDEX CONCURRENTLY before deploying (then `IF NOT EXISTS` no-ops
|
||||
* here). Small/typical tenants are unaffected.
|
||||
*/
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
// Title trigram index — matches the lookup-mode title predicate exactly.
|
||||
// Already present from #348 on existing DBs; re-asserted for freshness.
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_title_trgm
|
||||
ON pages USING gin ((LOWER(f_unaccent(title))) gin_trgm_ops)
|
||||
`.execute(db);
|
||||
|
||||
// text_content trigram index — accelerates the lookup-mode text substring
|
||||
// predicate. Expression matches the predicate in search.service.ts.
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_text_content_trgm
|
||||
ON pages USING gin ((LOWER(f_unaccent(text_content))) gin_trgm_ops)
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
// Only drop the index this migration introduced. idx_pages_title_trgm is owned
|
||||
// by the #348 perf-indexes migration, so leave it for that migration's down().
|
||||
await sql`DROP INDEX IF EXISTS idx_pages_text_content_trgm`.execute(db);
|
||||
}
|
||||
Reference in New Issue
Block a user