meta(search): оверхол поиска — гибрид лексика+вектор, единый путь (ru_en, OR+операторы, RRF, TEI e5-small) #527

Open
opened 2026-07-12 01:15:06 +03:00 by agent_vscode · 1 comment
Collaborator

Проблема / Цель

Пользовательский и агентский поиск дают ложные пустые выдачи на нормальных запросах и не ранжируют по релевантности. Замерено на живой базе (спейс Obsidian, через MCP search):

  • «Стамбул Роснефть» → [] (строгий AND: слова на разных страницах).
  • «ресторанов москвы» → [] при существующей странице «ресторан в москве» (english-конфиг FTS не стеммит русский: «ресторанов»≠«ресторан», «москвы»≠«москве»).
  • «печат» → тащит «вПЕЧАТления», «Путешествия»(впечатления) — подстрока внутри слов.
  • «3D принтер» → только 2 страницы с обоими словами; печать-страницы («Модели для печати», «Хотет напечатать»), всплывающие на «печат»/«принтер» по отдельности, — пропадают.

Цель: лучший поиск — предсказуемое релевантностное ранжирование (OR-дефолт), русская+английская морфология, операторы запроса, пагинация, гибрид лексика+вектор (семантика), единый движок для веб-UI, MCP-агента и share. Пустая выдача — только когда реально ничего не совпало.

Контекст и исследование

Затронутые подсистемы (репозиторий, SEARCH_DRIVER=database; на typesense поведение не меняется — вне scope):

  • apps/server/src/core/search/search.service.ts — два пути в searchPage(): FTS (веб-UI, to_tsquery('english', …), строгий AND) и lookup (substring:true, всегда шлёт MCP; гибрид LIKE-всей-строки + FTS, тир-ранжирование, path/snippet, без total/hasMore/offset, cap 200).
  • tsv — колонка через триггер pages_tsvector_trigger (миграции 20240324T086800, 20250729T213756): setweight(to_tsvector('english', f_unaccent(title)),'A') || setweight(to_tsvector('english', f_unaccent(text_content)),'B').
  • Векторы: page_embeddings (pgvector, chunk-level) + hybridSearch() RRF, k=60 (apps/server/src/database/repos/ai-chat/page-embedding.repo.ts) — используется только RAG AI-агента, не пользовательским поиском. Вектор dimension-agnostic → seq scan (нет ANN). page_embeddings.fts — generated to_tsvector('english', …); query-конфиг в hybridSearch захардкожен websearch_to_tsquery('english', …) (:252).
  • Эмбеддинги: асинхронный embedding-indexer.service.ts по событиям PAGE_CREATED/UPDATED; провайдер per-workspace (ai_provider_credentials); сейчас документы эмбедятся без e5-префикса; no-op без провайдера.
  • Пермишены: filterAccessiblePageIds (page-permission.repo.ts) — fast-path #348: ранний выход без рекурсивного CTE, когда ограниченных страниц в скоупе нет.
  • SEARCH_DRIVER=database|typesense (environment.*): typesense-контроллер обходит searchPage().
  • MCP: packages/mcp/src/client/read.ts search() всегда шлёт substring:true; tool-specs.ts + server-instructions.ts (enforced тестом).

Смежное: #443 (closed) осознанно развёл веб-поиск (in-app RRF) и агентский (substring) — этот оверхол разворот объединяет их (по цели «лучший поиск в UI, лексика+вектора»). #471 (open) — утечка title предка в breadcrumbs (см. ниже, переоценено). #293 — дрейф зеркал схемы (наш парсер не должен создавать новое зеркало: единый источник — сервер).

Дизайн (обзор; детали — в ишью фаз)

Единый SearchService для UI/агента/share. Внутри — ветки, слитые единым примитивом RRF (k=60): (1) лексика pages.tsv (ru_en, ts_rank_cd, веса A/B), (2) substring/trigram для ID/host (match=auto), (3) [фаза B] вектор page_embeddings (cosine, chunk→page). Три несопоставимые шкалы (ts_rank/тир/cosine) НЕ складываются — каждая ветка ранжируется отдельно, RRF комбинирует ранги.

Единый bilingual конфиг ru_en (russian_stem для кириллицы + english_stem для латиницы) во всех местах: триггер pages.tsv, generated page_embeddings.fts, и query-конфиг hybridSearch (:252) — конфиг колонки и конфиг запроса всегда меняются парой (иначе тихо ломается лексическое плечо RAG).

Жизненный цикл семантики (спроектирован целиком, реализуется в B)

Ключ версии эмбеддингов = фингерпринт {modelId + revision + prefixScheme + dimensions} (не голое modelName — иначе смена revision/префикса не детектится). Активный фингерпринт хранится в настройках воркспейса; вектор-CTE и подсчёт покрытия фильтруют по нему.

Состояние Условие Поведение поиска
off нет провайдера эмбеддингов чистая лексика; semantic.available=false, reason=no-provider
stale/reindexing строк активного фингерпринта < embeddable-страниц (смена модели/префикса или идёт реиндекс) лексика + старый фингерпринт до атомарного флипа; reason=stale
full строк активного фингерпринта == embeddable гибрид лексика+вектор

Реиндекс при смене фингерпринта: новые строки пишутся с новым фингерпринтом, старые не удаляются до конца; указатель активного фингерпринта флипается атомарно только после полного реиндекса (и только если конфиг всё ещё == target); затем идемпотентный GC (кап 2 генерации, ~2× storage на время). deleteByPage во время свапа — model-scoped (иначе правка страницы выкидывает её из семантики). Знаменатель покрытия = страницы, реально давшие ≥1 chunk.

Сквозные инварианты

  • Пермишены fail-closed: anti-join в SQL (условно по hasRestricted, чтобы сохранить fast-path #348); ошибка permission-запроса → 500, НЕ пустая лексика. total — permission-filtered (не течёт кардинальностью скрытых).
  • TEI/эмбеддинг fail-open к лексике, но видимо: структурный лог с фиксированным кодом события + healthcheck TEI + флаг semantic в ответе (серверной метрик-инфраструктуры в проекте нет — не обещаем метрик-сток). try/catch деградации оборачивает только embed/вектор-ветку.
  • Пагинация: CANDIDATE_CAP (env, 500) — окно фьюжна; детерминированный ORDER BY rrf DESC, id; hasMore; при упоре в CAP — truncatedAtCap:true (хвост за CAP недостижим — зафиксировать в tool-spec).

Границы scope

  • Только SEARCH_DRIVER=database. Typesense не трогаем.
  • НЕ входит: ANN/HNSW (остаётся seq scan — осознанно, «пока seq scan»), реранкер.

Фазы (крупная нарезка)

  • Фаза A — Лексический оверхол (ишью подаётся отдельно): ru_en (+синхронно page_embeddings.fts и hybridSearch:252), парсер с операторами, OR+релевантность через RRF (лексика+substring), match=auto, пагинация/total, единый путь UI/агент/share, superset ответа, MCP+web-UI. Independently shippable — даёт лучший поиск без вектора.
  • Фаза B — Семантический слой (blocked by A): TEI-сайдкар (e5-small), query-embedding, hybridSearchPages (добавляет вектор-ветку в RRF), фингерпринт+lifecycle семантики, best-effort gating, наблюдаемость.

Совместимость и миграции

  • Дефолт веб-UI меняется AND→OR — явное изменение. SEARCH_MODE=or|and — тумблер парсера (оба валидны на ru_en-tsv), НЕ откат конфига. Настоящий откат = down-миграция + полный реиндекс (минуты-часы, задокументировано).
  • Миграция ru_en: реиндекс данных ДО флипа read-пути (гейт migration_complete) или dual-config на окно (искать по обоим конфигам), чтобы страницы, правленые в окне, не давали морфологический рассинхрон; реиндекс — процедурный батч-джоб вне единой Kysely-транзакции (SEARCH_REINDEX_BATCH, tsv через touch строк); page_embeddings.fts — бэкфиллом без длинного ACCESS EXCLUSIVE. down(): сперва вернуть tsv/fts на english, потом DROP объекта конфига ru_en (иначе падение по зависимостям).

Связи

Разворачивает #443. Ссылается на #471 (path-утечка — переоценено в ишью A), #293 (не плодить зеркала — парсер на сервере, единый источник). Фазы: A → B (B blocked by A).

## Проблема / Цель Пользовательский и агентский поиск дают **ложные пустые выдачи** на нормальных запросах и не ранжируют по релевантности. Замерено на живой базе (спейс Obsidian, через MCP `search`): - «Стамбул Роснефть» → `[]` (строгий AND: слова на разных страницах). - «ресторанов москвы» → `[]` при существующей странице «ресторан в москве» (english-конфиг FTS не стеммит русский: «ресторанов»≠«ресторан», «москвы»≠«москве»). - «печат» → тащит «вПЕЧАТления», «Путешествия»(впечатления) — подстрока внутри слов. - «3D принтер» → только 2 страницы с обоими словами; печать-страницы («Модели для печати», «Хотет напечатать»), всплывающие на «печат»/«принтер» по отдельности, — пропадают. Цель: **лучший поиск** — предсказуемое релевантностное ранжирование (OR-дефолт), русская+английская морфология, операторы запроса, пагинация, гибрид **лексика+вектор** (семантика), **единый движок** для веб-UI, MCP-агента и share. Пустая выдача — только когда реально ничего не совпало. ## Контекст и исследование Затронутые подсистемы (репозиторий, `SEARCH_DRIVER=database`; на `typesense` поведение не меняется — вне scope): - `apps/server/src/core/search/search.service.ts` — два пути в `searchPage()`: FTS (веб-UI, `to_tsquery('english', …)`, строгий AND) и lookup (`substring:true`, всегда шлёт MCP; гибрид LIKE-всей-строки + FTS, тир-ранжирование, path/snippet, без total/hasMore/offset, cap 200). - `tsv` — колонка через **триггер** `pages_tsvector_trigger` (миграции `20240324T086800`, `20250729T213756`): `setweight(to_tsvector('english', f_unaccent(title)),'A') || setweight(to_tsvector('english', f_unaccent(text_content)),'B')`. - Векторы: `page_embeddings` (pgvector, chunk-level) + `hybridSearch()` **RRF, k=60** ([apps/server/src/database/repos/ai-chat/page-embedding.repo.ts](apps/server/src/database/repos/ai-chat/page-embedding.repo.ts)) — используется **только** RAG AI-агента, не пользовательским поиском. Вектор dimension-agnostic → seq scan (нет ANN). `page_embeddings.fts` — generated `to_tsvector('english', …)`; query-конфиг в `hybridSearch` захардкожен `websearch_to_tsquery('english', …)` (:252). - Эмбеддинги: асинхронный `embedding-indexer.service.ts` по событиям `PAGE_CREATED/UPDATED`; провайдер per-workspace (`ai_provider_credentials`); **сейчас документы эмбедятся без e5-префикса**; no-op без провайдера. - Пермишены: `filterAccessiblePageIds` ([page-permission.repo.ts](apps/server/src/database/repos/page/page-permission.repo.ts)) — **fast-path #348**: ранний выход без рекурсивного CTE, когда ограниченных страниц в скоупе нет. - `SEARCH_DRIVER=database|typesense` (`environment.*`): typesense-контроллер **обходит** `searchPage()`. - MCP: [packages/mcp/src/client/read.ts](packages/mcp/src/client/read.ts) `search()` всегда шлёт `substring:true`; `tool-specs.ts` + `server-instructions.ts` (enforced тестом). Смежное: **#443** (closed) осознанно **развёл** веб-поиск (in-app RRF) и агентский (substring) — этот оверхол разворот объединяет их (по цели «лучший поиск в UI, лексика+вектора»). **#471** (open) — утечка title предка в breadcrumbs (см. ниже, переоценено). **#293** — дрейф зеркал схемы (наш парсер не должен создавать новое зеркало: единый источник — сервер). ## Дизайн (обзор; детали — в ишью фаз) Единый `SearchService` для UI/агента/share. Внутри — ветки, слитые **единым примитивом RRF** (k=60): (1) лексика `pages.tsv` (ru_en, `ts_rank_cd`, веса A/B), (2) substring/trigram для ID/host (match=auto), (3) [фаза B] вектор `page_embeddings` (cosine, chunk→page). Три несопоставимые шкалы (ts_rank/тир/cosine) НЕ складываются — каждая ветка ранжируется отдельно, RRF комбинирует **ранги**. **Единый bilingual конфиг `ru_en`** (russian_stem для кириллицы + english_stem для латиницы) во всех местах: триггер `pages.tsv`, generated `page_embeddings.fts`, и query-конфиг `hybridSearch` (:252) — **конфиг колонки и конфиг запроса всегда меняются парой** (иначе тихо ломается лексическое плечо RAG). ### Жизненный цикл семантики (спроектирован целиком, реализуется в B) Ключ версии эмбеддингов = **фингерпринт** `{modelId + revision + prefixScheme + dimensions}` (не голое `modelName` — иначе смена revision/префикса не детектится). Активный фингерпринт хранится в настройках воркспейса; вектор-CTE и подсчёт покрытия фильтруют по нему. | Состояние | Условие | Поведение поиска | |---|---|---| | off | нет провайдера эмбеддингов | чистая лексика; `semantic.available=false, reason=no-provider` | | stale/reindexing | строк активного фингерпринта < embeddable-страниц (смена модели/префикса или идёт реиндекс) | лексика + старый фингерпринт до атомарного флипа; `reason=stale` | | full | строк активного фингерпринта == embeddable | гибрид лексика+вектор | Реиндекс при смене фингерпринта: новые строки пишутся с новым фингерпринтом, старые **не удаляются** до конца; указатель активного фингерпринта флипается **атомарно** только после полного реиндекса (и только если конфиг всё ещё == target); затем идемпотентный GC (кап 2 генерации, ~2× storage на время). `deleteByPage` во время свапа — **model-scoped** (иначе правка страницы выкидывает её из семантики). Знаменатель покрытия = страницы, реально давшие ≥1 chunk. ### Сквозные инварианты - **Пермишены fail-closed**: anti-join в SQL (условно по `hasRestricted`, чтобы сохранить fast-path #348); ошибка permission-запроса → 500, НЕ пустая лексика. `total` — permission-filtered (не течёт кардинальностью скрытых). - **TEI/эмбеддинг fail-open** к лексике, но **видимо**: структурный лог с фиксированным кодом события + healthcheck TEI + флаг `semantic` в ответе (серверной метрик-инфраструктуры в проекте нет — не обещаем метрик-сток). try/catch деградации оборачивает **только** embed/вектор-ветку. - **Пагинация**: `CANDIDATE_CAP` (env, 500) — окно фьюжна; детерминированный `ORDER BY rrf DESC, id`; `hasMore`; при упоре в CAP — `truncatedAtCap:true` (хвост за CAP недостижим — зафиксировать в tool-spec). ## Границы scope - Только `SEARCH_DRIVER=database`. Typesense не трогаем. - НЕ входит: ANN/HNSW (остаётся seq scan — осознанно, «пока seq scan»), реранкер. ## Фазы (крупная нарезка) - **Фаза A — Лексический оверхол** (ишью подаётся отдельно): ru_en (+синхронно `page_embeddings.fts` и `hybridSearch`:252), парсер с операторами, OR+релевантность через RRF (лексика+substring), match=auto, пагинация/total, единый путь UI/агент/share, superset ответа, MCP+web-UI. Independently shippable — даёт лучший поиск без вектора. - **Фаза B — Семантический слой** (blocked by A): TEI-сайдкар (e5-small), query-embedding, `hybridSearchPages` (добавляет вектор-ветку в RRF), фингерпринт+lifecycle семантики, best-effort gating, наблюдаемость. ## Совместимость и миграции - Дефолт веб-UI меняется AND→OR — явное изменение. `SEARCH_MODE=or|and` — тумблер **парсера** (оба валидны на ru_en-tsv), **НЕ откат конфига**. Настоящий откат = down-миграция + полный реиндекс (минуты-часы, задокументировано). - Миграция ru_en: реиндекс данных ДО флипа read-пути (гейт `migration_complete`) или **dual-config на окно** (искать по обоим конфигам), чтобы страницы, правленые в окне, не давали морфологический рассинхрон; реиндекс — процедурный батч-джоб **вне** единой Kysely-транзакции (`SEARCH_REINDEX_BATCH`, tsv через touch строк); `page_embeddings.fts` — бэкфиллом без длинного ACCESS EXCLUSIVE. down(): сперва вернуть tsv/fts на english, потом DROP объекта конфига ru_en (иначе падение по зависимостям). ## Связи Разворачивает **#443**. Ссылается на **#471** (path-утечка — переоценено в ишью A), **#293** (не плодить зеркала — парсер на сервере, единый источник). Фазы: A → B (B blocked by A).
Author
Collaborator

Фазы поданы:

  • #529 — Фаза A: лексический оверхол (ru_en, парсер+операторы, OR/RRF, match=auto, пагинация, единый путь). Independently shippable.
  • #530 — Фаза B: семантический слой (TEI e5-small + гибрид вектор+RRF, best-effort). Blocked by #529.

Порядок: #529#530.

Фазы поданы: - **#529** — Фаза A: лексический оверхол (ru_en, парсер+операторы, OR/RRF, match=auto, пагинация, единый путь). Independently shippable. - **#530** — Фаза B: семантический слой (TEI e5-small + гибрид вектор+RRF, best-effort). Blocked by #529. Порядок: #529 → #530.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#527