feat(search) Фаза B: семантический слой — TEI e5-small сайдкар + гибрид вектор+RRF (best-effort) #530

Closed
opened 2026-07-12 01:17:21 +03:00 by agent_vscode · 0 comments
Collaborator

Фаза B мета-ишью #527. Blocked by фазой A (ru_en, единый пайплайн, RRF-примитив, page_embeddings.fts на ru_en). Только SEARCH_DRIVER=database.

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

Лексика (даже починенная в A) не ловит концептуальные запросы («3D принтер» ↔ «модели для печати»). Поднять существующий RAG-примитив (hybridSearch RRF) до пользовательского поиска: добавить вектор-ветку в RRF из A, best-effort (нет эмбеддингов/TEI → чистая лексика, поиск всегда работает). Эмбеддинги — локальные: сайдкар HF TEI + multilingual-e5-small.

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

page_embeddings (pgvector, chunk-level, dimension-agnostic → seq scan) + hybridSearch() RRF k=60 (page-embedding.repo.ts) — chunk-level, для RAG. embedding-indexer.service.ts (async, событийный, per-workspace провайдер; deleteByPage модель-слепой :70; reindexWorkspace последовательный; getEmbeddablePageIds — предикат embeddable-страниц). embedTexts ai.service.ts:350 — провайдер-агностичный, таймаут AI_EMBEDDING_TIMEOUT_MS дефолт 120000мс (батчевый). modelName per-row = грубый ключ (typeof model==='string'?model:model.modelId??'unknown'). Документы сейчас эмбедятся без e5-префикса. Серверной метрик-инфраструктуры НЕТ (только клиентский web-vitals telemetry/*).

Дизайн

B1. TEI-сайдкар (глобальный дефолт-провайдер эмбеддингов)

docker-compose.yml: сервис embeddings = ghcr.io/huggingface/text-embeddings-inference:cpu-1.9, --model-id intfloat/multilingual-e5-small --revision <sha>, volume tei-models:/data (модель НЕ в образе), healthcheck, OpenAI-совм. /v1/embeddings. .env.example: глобальный embedding-провайдер (endpoint http://embeddings:80/v1) + prefix-схема (EMBEDDING_QUERY_PREFIX="query: ", EMBEDDING_DOC_PREFIX="passage: " для e5; пусто для не-e5). Prefix-схема — свойство провайдера, не per-workspace-креды (в ai_provider_credentials поля под префикс нет).

B2. Эмбеддинг запроса (интерактивный)

embedQuery(text) кладёт query-префикс из конфига провайдера; отдельный интерактивный таймаут SEARCH_EMBED_TIMEOUT_MS (env, дефолт 800мс, свой AbortSignal) — НЕ наследовать батчевый 120с. Ошибка/таймаут → degrade к лексике (см. B5).

B3. Гибрид page-level (расширяет RRF из A)

Новый hybridSearchPages: тот же парсер A2 + конфиг ru_en в лексической ветке (НЕ websearch_to_tsquery('english')); вектор-ветка — cosine <=> по page_embeddings, агрегация chunk→page (лучший chunk), фильтр по активному фингерпринту (B4). Обе ветки → RRF-примитив из A (третья ветка). Пермишены/CAP/пагинация/total/superset/snippet — как в A (over-fetch по CANDIDATE_CAP → SQL-anti-join условно по hasRestricted → срез). Snippet page-level: приоритет — окно вокруг лексического совпадения; для вектор-only хита — ведущее окно текста/лучший chunk.

B4. Жизненный цикл семантики

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

Состояние Условие reason
off нет провайдера no-provider
stale/reindexing rows(активный fp) < embeddable stale
full rows(активный fp) == embeddable

Знаменатель покрытия = страницы, реально давшие ≥1 chunk (не сырой getEmbeddablePageIds, иначе вечный stale); full ставится по завершению прогона реиндекса, count — грубый индикатор. Ответ: semantic:{state, available, reason?, indexed?, total?}.

Реиндекс при смене фингерпринта (не-деструктивно):

  • Новые строки пишутся с новым fp; старые не удаляются до конца.
  • Джоб несёт целевой fp; флип указателя активного fp — атомарно (одна запись настроек) только после полного реиндекса и только если конфиг всё ещё == target (иначе второй свап во время первого залипает в stale).
  • deleteByPage во время свапа — model/fp-scoped (WHERE ... по fp), иначе правка страницы удалит все её строки и выкинет из семантики до конца окна.
  • GC — идемпотентно и на старте свапа (чистить не-активные/не-целевые генерации), кап 2 генерации; задокументировать транзиентные ~2× storage pgvector (+ seq-scan читает мёртвые строки → латентность, если не GC).
  • Патч индексатора: класть doc-префикс из конфига; включение префикса меняет fp → форс-реиндекс автоматически.

B5. Отказы и наблюдаемость

TEI/эмбеддинг падает → fail-OPEN к лексике, но видимо: структурный лог с фиксированным кодом события (search.semantic.degraded) + healthcheck TEI + флаг semantic.available=false в ответе. Метрик-стока в проекте нет — не обещаем метрику; наблюдаемость = лог+healthcheck+флаг (MVP). try/catch деградации оборачивает только embedQuery/вектор-ветку; исключение filterAccessiblePageIds пробрасывается → 500 (fail-closed).

Границы scope

Только database-драйвер. НЕ входит: ANN/HNSW (seq scan), реранкер, GPU-профиль (тот же образ, смена тега — отдельно).

Критерии приёмки (стенд)

  1. «3D принтер» → «Модели для печати» (без слова «принтер») в топе за счёт вектора.
  2. TEI остановлен → поиск ВСЁ РАВНО отдаёт лексику; semantic.available=false; в логе событие деградации.
  3. Воркспейс без провайдера → лексика, semantic.reason=no-provider, не 500.
  4. Пермишен-фильтр применён к объединённому (лексика+вектор) набору; ошибка permission-запроса → 500, не открытая лексика.
  5. Смена --revision/включение префикса → semantic.state=stale, вектор сервится по старому fp до завершения реиндекса, после — по новому; same-dim свап не даёт мусорную выдачу.
  6. Правка страницы во время реиндекса → страница не исчезает из семантики (fp-scoped deleteByPage).
  7. Интерактивный запрос при зависшем (не упавшем) TEI деградирует за ~800мс, не за 120с.
  8. p95 гибрида на ~600 стр. ≤ цель (baseline — задача 1 ишью: замер текущей латентности /search; метод — curl-серия на стенде).

Отказы и наблюдаемость

См. B5. Реиндекс: аборт батча (isFatalProviderError) не флипает указатель, GC на старте чистит частичные генерации.

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

Новый сайдкар — rollback = убрать сервис (best-effort → лексика). Реиндекс page_embeddings при первом включении префикса/смене модели — через (пропатченный) индексатор, fp-driven.

Решённые развилки

seq scan (человек); e5-small + TEI-сайдкар (человек); best-effort+fallback (человек); фингерпринт вместо modelName (дизайн, hostile-A/N2); лёгкий не-деструктивный реиндекс + атомарный флип вместо теневых таблиц (дизайн, контр-предложение); fp-scoped deleteByPage (hostile-D); отдельный интерактивный таймаут (ops-5); наблюдаемость = лог+healthcheck+флаг, метрик-стока нет (ops-4); prefix — свойство глобального провайдера (impl-F4).

Связи

Мета #527. Blocked by фаза A. Переиспользует page-embedding.repo (RRF), embedding-indexer.

Фаза B мета-ишью **#527**. **Blocked by фазой A** (ru_en, единый пайплайн, RRF-примитив, `page_embeddings.fts` на ru_en). Только `SEARCH_DRIVER=database`. ## Проблема / Цель Лексика (даже починенная в A) не ловит концептуальные запросы («3D принтер» ↔ «модели для печати»). Поднять существующий RAG-примитив (`hybridSearch` RRF) до пользовательского поиска: добавить вектор-ветку в RRF из A, **best-effort** (нет эмбеддингов/TEI → чистая лексика, поиск всегда работает). Эмбеддинги — локальные: сайдкар **HF TEI + multilingual-e5-small**. ## Контекст и исследование `page_embeddings` (pgvector, chunk-level, dimension-agnostic → seq scan) + `hybridSearch()` RRF k=60 ([page-embedding.repo.ts](apps/server/src/database/repos/ai-chat/page-embedding.repo.ts)) — chunk-level, для RAG. `embedding-indexer.service.ts` (async, событийный, per-workspace провайдер; `deleteByPage` модель-слепой :70; `reindexWorkspace` последовательный; `getEmbeddablePageIds` — предикат embeddable-страниц). `embedTexts` [ai.service.ts:350](apps/server/src/integrations/ai/ai.service.ts) — провайдер-агностичный, таймаут `AI_EMBEDDING_TIMEOUT_MS` дефолт **120000мс** (батчевый). `modelName` per-row = грубый ключ (`typeof model==='string'?model:model.modelId??'unknown'`). Документы **сейчас эмбедятся без e5-префикса**. Серверной метрик-инфраструктуры НЕТ (только клиентский web-vitals `telemetry/*`). ## Дизайн ### B1. TEI-сайдкар (глобальный дефолт-провайдер эмбеддингов) `docker-compose.yml`: сервис `embeddings` = `ghcr.io/huggingface/text-embeddings-inference:cpu-1.9`, `--model-id intfloat/multilingual-e5-small --revision <sha>`, volume `tei-models:/data` (модель НЕ в образе), healthcheck, OpenAI-совм. `/v1/embeddings`. `.env.example`: глобальный embedding-провайдер (endpoint `http://embeddings:80/v1`) + **prefix-схема** (`EMBEDDING_QUERY_PREFIX="query: "`, `EMBEDDING_DOC_PREFIX="passage: "` для e5; пусто для не-e5). Prefix-схема — **свойство провайдера**, не per-workspace-креды (в `ai_provider_credentials` поля под префикс нет). ### B2. Эмбеддинг запроса (интерактивный) `embedQuery(text)` кладёт query-префикс из конфига провайдера; **отдельный интерактивный таймаут** `SEARCH_EMBED_TIMEOUT_MS` (env, дефолт 800мс, свой AbortSignal) — НЕ наследовать батчевый 120с. Ошибка/таймаут → degrade к лексике (см. B5). ### B3. Гибрид page-level (расширяет RRF из A) Новый `hybridSearchPages`: **тот же парсер A2 + конфиг ru_en** в лексической ветке (НЕ `websearch_to_tsquery('english')`); вектор-ветка — cosine `<=>` по `page_embeddings`, агрегация chunk→page (лучший chunk), фильтр по **активному фингерпринту** (B4). Обе ветки → **RRF-примитив из A** (третья ветка). Пермишены/CAP/пагинация/total/superset/snippet — как в A (over-fetch по CANDIDATE_CAP → SQL-anti-join условно по hasRestricted → срез). Snippet page-level: приоритет — окно вокруг лексического совпадения; для вектор-only хита — ведущее окно текста/лучший chunk. ### B4. Жизненный цикл семантики Ключ версии = **фингерпринт** `{modelId + revision + prefixScheme + dimensions}` (не голое `modelName` — иначе смена revision/включение префикса не детектится: та же e5/384). Активный фингерпринт — в настройках воркспейса. Вектор-CTE и подсчёт покрытия фильтруют по нему. | Состояние | Условие | reason | |---|---|---| | off | нет провайдера | no-provider | | stale/reindexing | rows(активный fp) < embeddable | stale | | full | rows(активный fp) == embeddable | — | Знаменатель покрытия = страницы, реально давшие **≥1 chunk** (не сырой `getEmbeddablePageIds`, иначе вечный stale); `full` ставится по завершению прогона реиндекса, count — грубый индикатор. Ответ: `semantic:{state, available, reason?, indexed?, total?}`. **Реиндекс при смене фингерпринта** (не-деструктивно): - Новые строки пишутся с новым fp; **старые не удаляются** до конца. - Джоб несёт **целевой fp**; флип указателя активного fp — **атомарно** (одна запись настроек) только после полного реиндекса **и только если конфиг всё ещё == target** (иначе второй свап во время первого залипает в stale). - `deleteByPage` во время свапа — **model/fp-scoped** (`WHERE ...` по fp), иначе правка страницы удалит все её строки и выкинет из семантики до конца окна. - GC — **идемпотентно и на старте свапа** (чистить не-активные/не-целевые генерации), кап 2 генерации; задокументировать транзиентные ~2× storage pgvector (+ seq-scan читает мёртвые строки → латентность, если не GC). - Патч индексатора: класть **doc-префикс** из конфига; включение префикса меняет fp → форс-реиндекс автоматически. ### B5. Отказы и наблюдаемость TEI/эмбеддинг падает → **fail-OPEN** к лексике, но **видимо**: структурный лог с фиксированным кодом события (`search.semantic.degraded`) + healthcheck TEI + флаг `semantic.available=false` в ответе. Метрик-стока в проекте нет — не обещаем метрику; наблюдаемость = лог+healthcheck+флаг (MVP). try/catch деградации оборачивает **только** embedQuery/вектор-ветку; исключение `filterAccessiblePageIds` пробрасывается → 500 (fail-closed). ## Границы scope Только database-драйвер. НЕ входит: ANN/HNSW (seq scan), реранкер, GPU-профиль (тот же образ, смена тега — отдельно). ## Критерии приёмки (стенд) 1. «3D принтер» → «Модели для печати» (без слова «принтер») в топе за счёт вектора. 2. TEI остановлен → поиск ВСЁ РАВНО отдаёт лексику; `semantic.available=false`; в логе событие деградации. 3. Воркспейс без провайдера → лексика, `semantic.reason=no-provider`, не 500. 4. Пермишен-фильтр применён к объединённому (лексика+вектор) набору; ошибка permission-запроса → 500, не открытая лексика. 5. Смена `--revision`/включение префикса → `semantic.state=stale`, вектор сервится по старому fp до завершения реиндекса, после — по новому; **same-dim свап не даёт мусорную выдачу**. 6. Правка страницы во время реиндекса → страница не исчезает из семантики (fp-scoped deleteByPage). 7. Интерактивный запрос при зависшем (не упавшем) TEI деградирует за ~800мс, не за 120с. 8. p95 гибрида на ~600 стр. ≤ цель (**baseline — задача 1 ишью**: замер текущей латентности `/search`; метод — curl-серия на стенде). ## Отказы и наблюдаемость См. B5. Реиндекс: аборт батча (`isFatalProviderError`) не флипает указатель, GC на старте чистит частичные генерации. ## Совместимость и миграции Новый сайдкар — rollback = убрать сервис (best-effort → лексика). Реиндекс page_embeddings при первом включении префикса/смене модели — через (пропатченный) индексатор, fp-driven. ## Решённые развилки seq scan (человек); e5-small + TEI-сайдкар (человек); best-effort+fallback (человек); фингерпринт вместо modelName (дизайн, hostile-A/N2); лёгкий не-деструктивный реиндекс + атомарный флип вместо теневых таблиц (дизайн, контр-предложение); fp-scoped deleteByPage (hostile-D); отдельный интерактивный таймаут (ops-5); наблюдаемость = лог+healthcheck+флаг, метрик-стока нет (ops-4); prefix — свойство глобального провайдера (impl-F4). ## Связи Мета **#527**. Blocked by фаза A. Переиспользует `page-embedding.repo` (RRF), `embedding-indexer`.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#530