Runbook: matching & analogs search (facts projection + pgvector)
Сводный operations-документ по двум проекциям:
offer_characteristic_facts(latest snapshot для matcher candidate_reader) и pgvector-MVP (canonical_products.name_embeddingдля/v1/canonical/{id}/analogs). Здесь же — slow-log мониторинг и индексные дороги, проложенные в рамках раунда оптимизаций 2026-05-12.
Severity (default): P2 Owner: backend / matching Связанные алерты:
facts_projection_lag_secondsfactsproj_events_failed_totalembedding_dirty_queue_depthpg_stat_statementstop-N (см. ниже)
1. Архитектурная карта
┌────────────────────────────┐
│ ingestion (supplier-sync, │
│ details_offer_enumerator) │
└────────────┬───────────────┘
│ INSERT offer_characteristic_raw (mapping_id may be NULL)
▼
┌────────────────────────────┐
│ offer_characteristic_raw │ ← append-only history, source of truth
└────────────┬───────────────┘
│ emit snapshot_replaced.v1 per (offer, supplier) [tx]
▼
┌────────────────────────────┐
│ outbox_events │ matching.char_facts.v1
└────────────┬───────────────┘
│ pull cursor-based (DELETE+RETURNING SKIP LOCKED не для outbox)
▼
┌────────────────────────────┐
│ facts-projector │ cmd/facts-projector, port 9096
│ tick 5s, tight-loop ≤50 │ fact_stats refresh every 10m
└────────────┬───────────────┘
│ ReplaceSnapshot (DELETE stale + UPSERT current,
│ observed_at-monotonic)
▼
┌────────────────────────────┐
│ offer_characteristic_facts │ ← latest snapshot, matcher reads from here
│ + char_fact_stats │ ← rarity guard MV, refreshed by projector
└────────────────────────────┘
┌────────────────────────────┐
│ canonical_products │ +name_embedding +name_tsvector
└────────────┬───────────────┘
│ triggers on (name UPDATE) and canonical_assignments
▼
┌────────────────────────────┐
│ canonical_embedding_dirty │ ← work queue, attempt_count < 5
└────────────┬───────────────┘
│ ClaimDirtyBatch (DELETE … RETURNING SKIP LOCKED)
▼
┌────────────────────────────┐
│ embedding-worker │ cmd/embedding-worker, port 9097
│ tick 30s │ noop when LLM_MODEL_EMBEDDING=""
└────────────┬───────────────┘
│ EmbeddingService.Embed → SaveEmbedding (vector + tsvector)
▼
┌────────────────────────────┐
│ GET /v1/canonical/{id}/ │ composite ranking:
│ analogs │ sem * 0.6 + txt * 0.2 + struct * 0.2
└────────────────────────────┘
1.1 Действующий путь подбора аналогов
Current implementation. Это PostgreSQL serving-путь для
GET /v1/canonical/{id}/analogs. Он не является описанным выше target-поиском на Elasticsearch и сохраняется как фактический контракт качества до его возможной замены.
- Ранкер получает canonical-якорь, его нормализованные характеристики и категорию.
- Строгая область кандидатов — категория якоря и члены её
category_equivalence-группы. Она не включает произвольные соседние или supplier-native категории. - Для характеристики строятся действующие identity-оси:
- подтверждённая запись значимости участвует только если прошла
CanTightenCandidateSet; - к ней добавляется seed из
charsemantics:rated_current, группа полюсов иtripping_characteristic/trip_curve; - ось без значения у якоря не действует.
- подтверждённая запись значимости участвует только если прошла
- Строгий пул требует совпадения всех действующих identity-осей. Если он
пуст, ранкер пробует relaxed-путь: он сохраняет те же identity-условия,
но может расширить область категориями, содержащими назначения с
broad_group_fallback. Это временно допускает товары из неразобранного мешка, но не из чужой разобранной категории. - На кандидаты накладываются
category_analog_rules(hard_divergence,monotone_ge,candidate_only_forbiddenи другие), затем они упорядочиваются по совпадению и весам значимости. Derived-значимость меняет вес, но не создаёт жёсткий фильтр.
Пример: для модульного автомата 3P C6A действуют оси 6A, 3 полюса и
кривая C. Модульные автоматы с теми же значениями остаются кандидатами,
дифавтоматы отсекаются candidate_only_forbidden, а товары из уже
классифицированных категорий аксессуаров не попадают в область поиска.
1.2 analog-quality-sweep: проверка всего каталога
backend/cmd/analog-quality-sweep — read-only команда, а не второй ранкер.
Она вызывает тот же AnalogsService, с теми же правилами, значимостью,
областью кандидатов и бюджетом публичного endpoint. HTTP намеренно не
используется: он добавил бы нагрузку api-server и скрыл бы фазовые тайминги,
но бюджет ответа остаётся тем же.
Для каждой eligible категории команда детерминированно выбирает до трёх
якорей и пишет построчный JSONL-артефакт. Соединение открывается с
default_transaction_read_only=on; попытка записи должна завершиться ошибкой
PostgreSQL. Checkpoint и stop-file позволяют безопасно останавливать и
продолжать длинный обход.
Каждая строка сохраняет действующие identity-оси, размеры строгого и relaxed-пулов, фазовые времена, результат и диагноз. Важно не смешивать результат с диагнозом:
| Поле | Пример | Смысл |
|---|---|---|
status | performance_failure | Что произошло с запросом: успешно ли он выполнился в публичном бюджете. |
empty_reason | no_effective_identity_keys | Почему запрос оказался пустым или дорогим. Не заменяет status. |
pool_sizes_unknown | true | Диагностика пула не уложилась в свой короткий срок; это не утверждение, что пул пуст. |
Статусы: passed, no_comparable_products, taxonomy_or_data_gap,
rule_gap, performance_failure, infra_failure,
diagnostics_incomplete. Пустая выдача не равна ошибке: sweep отделяет
отсутствие сопоставимых товаров от пробела таксономии, правила или
производительности.
Для сравнимого полного прогона нужно зафиксировать изменяемые входы: приостановить category reclassification и пересчёт значимости, сохранить метаданные до и после run. Иначе ранние и поздние якоря увидят разные категории и разные права broad fallback.
1.3 mapping-collision-audit: где одна ось склеила разные признаки поставщика
backend/cmd/mapping-collision-audit — read-only аудит сопоставлений
характеристик поставщика. Он ищет рискованную ситуацию: у одного поставщика
два разных исходных названия сведены в одну каноническую ось, причём одно из
сопоставлений ещё ожидает ревью, а другое уже подтверждено. Такой конфликт
может дать технически неверное значение на карточке и, если ось участвует в
контракте, неверно допустить кандидата в подбор аналогов.
Команда открывает PostgreSQL с default_transaction_read_only=on; любые
записи запрещены самой базой. Результат — JSONL-артефакт с одной строкой на
подозрительное сопоставление: поставщик, исходный код, целевая ось, число
затронутых канонов, число расходящихся значений, число текущих назначений,
следующих сомнительному значению, и примеры. timed_out=true означает, что
ущерб не измерен в заданный бюджет, а не что ущерба нет.
Основные параметры прогона:
| Параметр | Назначение |
|---|---|
-gating-only | оставить только оси, которые участвуют в контракте аналогов; начинать разбор с них |
-limit | ограничить число сопоставлений для пробного прогона |
-sample-canonicals | задать верхнюю границу канонов на одну строку; при усечении артефакт помечается sampled=true |
-statement-timeout | ограничить один запрос; дефолт 90 секунд |
-max-io-waiters | уступать ресурсы, когда в PostgreSQL уже много активных сессий, ожидающих I/O |
-stop-file | мягко остановить длинный прогон после уже записанных строк |
-mapping-id | добрать одну ранее неуспевшую строку с отдельным бюджетом |
Аудит не исправляет данные и не меняет правила аналогов. После него владелец каталога разбирает верхние строки по ущербу: разделяет оси или отклоняет сомнительное сопоставление, затем запускает обычную нормализацию и повторяет аудит. До решения конфликтная ось не должна трактоваться как доказательство инженерной эквивалентности.
2. Feature flags + env vars (всё через config.Config)
| Env | Default | Что |
|---|---|---|
MATCHER_USE_FACTS_PROJECTION | false | matcher candidate_reader: legacy ocr scan ↔ facts path |
MATCHER_FACTS_RARITY_THRESHOLD | 300 на prod временно | отсечка doc_count для generic характеристик; до RAM 64GB снижено с 1000, чтобы ограничить matcher Tier-2 fanout |
LLM_MODEL_EMBEDDING | "" | пусто = embedding-worker no-op; text-embedding-3-small — реальные embeddings |
WORKER_PAUSED_EMBEDDING_WORKER | true на prod | kill-switch ticker’а embedding-worker; держать true, пока gateway не отвечает на POST /v1/embeddings |
LLM_BASE_URL / LLM_API_KEY | (compose default) | shared CLIProxy для chat + embeddings |
ANALOGS_SEM_WEIGHT | 0.6 | вес косинус-similarity в /analogs |
ANALOGS_TEXT_WEIGHT | 0.2 | вес tsvector ts_rank |
ANALOGS_STRUCT_WEIGHT | 0.2 | вес structural overlap через offer_characteristic_facts |
ANALOGS_ADMIN_HTTP_TIMEOUT | 45s на prod временно | лимит только explainable admin endpoint; оставляет запас до 60s edge/statement timeout |
CATALOG_RECONCILER_BATCH_SIZE | 100 | hot reconciler PG/ES hash batch; снижать при ReadCanonicals statement timeout |
CATALOG_RECONCILER_COLD_CRON | 0 3 * * * UTC | ежедневный full audit текущего projection contract; invalid cron блокирует wiring |
CATALOG_RECONCILER_FULL_MAX_PASSES | 3 | максимум циклов enqueue → queue fence → verify до failed |
CATALOG_RECONCILER_FULL_FENCE_TIMEOUT | 12h | ожидание projector-событий с seq не выше fence данного прохода; бюджет покрывает initial manufacturer backfill при безопасном projector batch |
CATALOG_RECONCILER_FULL_RUN_TIMEOUT | 24h | общий бюджет одного startup/scheduled/manual full-run |
CATALOG_PROJECTOR_BATCH_SIZE | 200 на prod временно | projector queue batch; держать 200 до RAM 64GB и проверки p95 ReadCanonicals после LATERAL-фикса; 500 ещё ловил редкие 60s timeout под общей IO-нагрузкой |
CATALOG_PROJECTOR_OBSERVATION_WINDOW_DAYS | 7 на prod временно | окно свежести price/stock rollup в ES; держать 7 до расширения DB-VPS RAM до 64GB, затем пересмотреть возврат к 30 |
Прод-rollout порядок:
- Поднять
facts-projector(compose service уже есть, defaultfalseдля matcher). - Подождать пока
facts_projection_lag_seconds< 30s стабильно. - Флипнуть
MATCHER_USE_FACTS_PROJECTION=true, рестарт matcher-worker. - Через сутки —
pg_slow_queries.sh top: убедиться что legacy candidate_reader query ушёл из топа. - Проверить gateway:
POST /v1/embeddingsдолжен возвращать не404. Только после этого выставитьWORKER_PAUSED_EMBEDDING_WORKER=falseи поднятьembedding-workerсLLM_MODEL_EMBEDDING=text-embedding-3-small. - После полного backfill (или асимптотики
embedding_dirty_queue_depth → 0) —/v1/canonical/{id}/analogsготов отдавать 200.
Временное окно catalog-projector price/stock
С 2026-07-09 production CATALOG_PROJECTOR_OBSERVATION_WINDOW_DAYS=7 и
CATALOG_PROJECTOR_BATCH_SIZE=200.
Это временный DB-VPS mitigation до расширения RAM до 64GB и проверки p95
ReadCanonicals после LATERAL-фикса. На текущем объёме offer_observations
значение 30 затрагивает июньскую партицию offer_observations_2026_06
(~76GB) и ReadCanonicals регулярно упирался в
POSTGRES_STATEMENT_TIMEOUT=60s; проверка 2026-07-09 также показала, что
7d при batch 500 всё ещё даёт редкие timeout под общей IO-нагрузкой.
Окно влияет только на ES canonical rollup: has_price_any,
has_stock_any, suppliers_with_price, suppliers_with_stock.
Rollout нормализованного поиска по артикулу
identifier_index — добавочное keyword-поле ES-проекции канонического товара.
Оно содержит нормализованные viewable_id, артикул производителя и supplier SKU:
разделители игнорируются, русские и латинские х/x считаются одинаковыми.
Оно нужно для поиска внутреннего артикула, его фрагментов и значений с
./,.
Порядок выкладки:
- Выложить версию
catalog-projector/api-serverс mapping. На стартеEnsureIndexдобавит поле черезPUT _mapping; alias или новая версия индекса не нужны. - Подтвердить, что mapping текущего canonical-индекса содержит
identifier_indexтипаkeyword. - Поднять версию projection contract в коде reconciler-а. Standalone
catalog-reconcilerувидит отсутствие успешного запуска для нового contract и начнёт full-run при старте; далее тот же контур запускается поCATALOG_RECONCILER_COLD_CRONв UTC. РучнойPOST /api/v1/admin/catalog/projector/reindex?mode=fullостаётся аварийным повтором и использует тот же global advisory lock. - Дождаться строки
catalog_reconciler_full_runs.status='succeeded'сverified_drift=0. Full-run сначала ставит drift в очередь, ждёт все непроцессированные строки до сохранённогоqueue_fence, затем выполняет verify-only cold pass. Новые события сseq > queue_fenceне удерживают проход. Нулевая очередь без свежего zero-drift run не является доказательством исторической сходимости. - Проверить в «Канонических товарах» известную позицию: полный внутренний
артикул, его часть,
3120001142и3-12-0001142,3х2,5и3x2.5, а также односимвольную опечатку в названии. Опечатка допустима только для названия/производителя, не для идентификаторов. PostgreSQL данные,supplier_offersи историяoffer_observationsне удаляются. После увеличения RAM проверитьEXPLAIN/pg_stat_statementsдляReadCanonicalsи вернуть30/2000, если p95 укладывается в timeout.
Операционная проверка полного прохода:
SELECT run_id, projection_contract, trigger, status, started_at, finished_at,
passes, scanned, initial_drift, enqueued, verified_drift, queue_fence,
error
FROM catalog_reconciler_full_runs
ORDER BY started_at DESC
LIMIT 10;running старше CATALOG_RECONCILER_FULL_RUN_TIMEOUT и любой последний
failed требуют разбора логов reconciler/projector. Не менять
/srv/tracium/infra/deploy/.env вручную: расписание и бюджеты исправляются в
deploy/prod-env-template.env и compose mapping, затем выкладываются обычным
deploy.
Для contract catalog-canonical-v2-official-manufacturer-identity full audit
не перечитывает тяжёлую коммерческую проекцию каждого canonical. Он агрегирует
в ES уникальные (manufacturer_id, manufacturer_name.raw), сверяет этот
компактный набор с manufacturers, redirect-ами и однозначными global aliases,
затем scroll-ит и ставит в очередь только доказанный drift. Hot-loop продолжает
использовать projection hash для недавнего окна. Если новый projection contract
затрагивает другие поля, перед bump нужно расширить contract auditor и его
acceptance tests; одного изменения строки ProjectionContract недостаточно.
Production dry-run 2026-08-26 нашёл 1 188 488 canonical documents без
manufacturer_id, чьи имена однозначно разрешаются через 1 782 global
aliases/official names. Это реальный initial drift, а не повторная постановка
корректных документов. При безопасном CATALOG_PROJECTOR_BATCH_SIZE=200 его
дренирование занимает около 4–5 часов, поэтому fence budget меньше 12 часов
не использовать. После initial convergence ежедневные проходы должны ставить
только новый drift.
При рестарте владелец global advisory lock переводит оставшиеся от прежнего
процесса running rows в failed/superseded, а shutdown ждёт сохранения
cancelled-state. Поэтому свежий orphan running после deploy — дефект, а не
нормальное ожидание 24-часового stale timeout.
Временный timeout admin-аналогов
До расширения DB-VPS ANALOGS_ADMIN_HTTP_TIMEOUT=45s. Он действует только
на GET /api/v1/admin/catalog/canonical-products/{id}/analogs: public endpoint
сохраняет свой лимит 5s. Внешний edge для этого маршрута не подменяет typed
JSON 504 своей HTML-страницей.
Каждый завершённый запрос пишет structured event
catalog.analogs.admin.request с canonical_id, outcome, duration и
timeout. Для диагностики outcome=timeout|error сопоставлять с
pg_stat_statements, pg_slow_queries.sh active|waiting и состоянием
catalog-projector. Не повышать лимит выше 45s как замену устранению DB IO
pressure. После 64GB собрать 24h baseline и пересмотреть значение по p95/p99.
Post-64GB DB-VPS checklist
После расширения DB-VPS RAM до 64GB все изменения делать только через deploy:
- Поднять в
deploy/prod-env-template.envPostgres memory knobs:POSTGRES_SHARED_BUFFERS,POSTGRES_EFFECTIVE_CACHE_SIZE,POSTGRES_SHM_SIZE;POSTGRES_WORK_MEMне повышать глобально без отдельного расчёта по concurrency. - Задеплоить DB-VPS и собрать новый baseline
pg_stat_statementsминимум за 24 часа. - Проверить
ReadCanonicalsp95/p99 на текущихbatch=200,window=7. - Поэтапно вернуть
CATALOG_PROJECTOR_BATCH_SIZE=500, затем2000; после каждого шага проверить p95/p99 и отсутствиеSQLSTATE 57014. - Только после успешного batch-теста вернуть
CATALOG_PROJECTOR_OBSERVATION_WINDOW_DAYS=30и повторить p95/p99 проверку. - Перепроверить matcher slowlogs с
MATCHER_FACTS_RARITY_THRESHOLD=300. Если facts overlap больше не доминирует вpg_stat_statements, опционально протестировать прежнее значение1000на recall и сравнить query p95/p99 перед тем как оставить его. - Обновить этот runbook фактическими замерами и оставить итоговое значение env в deploy template.
3. Slow-query monitoring
CLI: ./scripts/pg_slow_queries.sh
./scripts/pg_slow_queries.sh top 20 # cumulative total_exec_time
./scripts/pg_slow_queries.sh slowest 10 # mean_exec_time
./scripts/pg_slow_queries.sh io 10 # shared_blks_read (cold-cache cost)
./scripts/pg_slow_queries.sh active # currently running
./scripts/pg_slow_queries.sh waiting # lock-waiting + blocker
./scripts/pg_slow_queries.sh indexes # large-seq-scan tables + unused indexes
./scripts/pg_slow_queries.sh reset # zero countersPostgres-side (compose command:): pg_stat_statements, track_io_timing=on, log_min_duration_statement=500, work_mem=64MB, hash_mem_multiplier=4, random_page_cost=1.1.
4. Накатанные индексы (раунд оптимизаций 2026-05-12)
| Migration | Что | Эффект |
|---|---|---|
| 0097 | pg_stat_statements + canonical_products(lower(name)) + supplier_offers WHERE category_id IS NULL | quality.Refresher ↓ |
| 0099 | offer_observations(offer_id) WHERE prices/stock_current <> '{}' | dataCollection 419s → 46s |
| 0101 | offer_characteristic_facts + 3 индекса | latest-snapshot for matcher |
| 0102 | char_facts_projector_cursor | projector state |
| 0103 | outbox_events(topic, id) | fetch range scan |
| 0104 | char_fact_stats materialized view | rarity guard |
| 0105 | offer_observations(offer_id) узкий single-col | no_obs Hash Anti Join 25.7s → 2.4s |
| 0106 | assignment_dispute_queue partial pending/in_progress | claim-path |
| 0107 | canonical_assignments(canonical_id, decided_at DESC) | LATERAL MAX 14.3s → 2.6s |
| 0108 | offer_characteristic_raw(mapping_id) WHERE raw_value ? ‘unit’ | unit-upgrade probe |
| 0109 | vector ext + canonical_products embedding columns + ivfflat + gin + dirty queue | pgvector MVP |
| 0110 | triggers canonical_products.name + canonical_assignments → dirty queue | поддержка dirty |
| 0111 | match_decisions partial (status=conflict / status=active) | ListCandidates path |
| 0234 | canonical_analog_assignment_lookup + triggers | bounded no-embedding fallback для analogs |
| 0235 | canonical_analog_assignment_lookup(canonical_id, characteristic_id) | fast trigger maintenance for assignment writes |
| 0237 | offer_characteristic_facts(offer_id, canonical_key, observed_at DESC) INCLUDE raw hash/mapping | post-rank latest facts для analog rule scoring |
Полные результаты per query — см. commit cbcd1a72, 7e4af2a7, b360bb3b.
5. Симптомы и действия
5.1 «Matcher грузит CPU», pg_slow_queries.sh top → candidate_reader 90%+
Если MATCHER_USE_FACTS_PROJECTION=false — это нормальный legacy путь. Флипнуть на true после проверки projector lag.
MATCHER_USE_MPN_NORM_COLUMN=true включает lookup по материализованной колонке
supplier_offers.mpn_norm; флипать только после backfill mpn_norm и готового
индекса supplier_offers_mpn_norm_idx.
Если true — посмотреть facts_projection_lag_seconds. Лаг > 5 мин: projector отстаёт от outbox.
5.1.1 Backlog matcher не уменьшается при коротких batch
Симптом: число supplier_offers без match_decisions остаётся большим, но
matcher-worker пишет loading_candidates с десятками или нулём позиций.
Полный точный backlog можно проверить анти-соединением ниже, но это
диагностический тяжёлый запрос, а не реализация каждого matcher tick. На
2,4 млн офферов не запускать его по кругу и ограничить statement timeout:
SET LOCAL statement_timeout = '60s';
SELECT count(*)
FROM supplier_offers so
WHERE NOT EXISTS (
SELECT 1 FROM match_decisions md WHERE md.offer_id = so.id
);Прежний ListCandidates выполнял этот глобальный anti-join каждую минуту. На
проде query набрал 275 вызовов, 8002 секунды выполнения и 25,7 млн физических
чтений блоков (~200 ГБ); под общим I/O каждый tick упирался в 60s timeout и
отбрасывал уже найденные recheck-кандидаты.
Текущий путь состоит из двух ограниченных полос:
- recent seed сначала читает не больше 1000 свежих unlocked-офферов, затем отбрасывает имеющие решение;
- historical sweep каждую UTC-минуту читает один из 1024 непересекающихся UUID PK ranges (hard limit 10 000). Полный круг занимает около 17 часов и не требует таблицы cursor или служебного UPDATE, поэтому переживает рестарты.
Так recent-поток попадает в ближайший tick, а старый undecided-tail не
голодает. Продовый EXPLAIN (ANALYZE, BUFFERS) bounded seeds: ~0,35s и 192
прочитанных блока даже во время full reconcile вместо глобального timeout.
Tier-3 parser принимает confidence и как JSON-число, и как числовую строку:
LLM-провайдеры могут вернуть оба эквивалентных представления, и это не должно
выбрасывать целый chunk undecided SKU.
5.1.2 match_decisions растёт по UPDATE/WAL без изменения решений
До исправления DecisionRepo.Upsert безусловно двигал updated_at, потому что
он одновременно служил курсором recheck. Продовый baseline: 4,1 млн UPDATE,
0 HOT UPDATE и 26,7 ГБ WAL по основному upsert query. Это отдельная write
amplification, даже если canonical assignment ниже уже умеет распознавать
no-op.
Теперь расписание не зависит от записи:
- probable/conflict распределены по UUID ranges на 24h;
- fallback
tier='none'— на 72h (4320 диапазонов, примерно 289 строк/мин при 1,25 млн решений); updated_at < 2001-01-01остаётся явным forced-rematch marker, обходит расписание и подтверждается ровно один раз;- сервис сравнивает новое решение с уже прочитанным и вообще не вызывает
persistence при равенстве; repository дополнительно защищён
ON CONFLICT ... DO UPDATE ... WHERE changed.
После выкладки проверить, что matcher-worker больше не пишет load_failed,
а рост pg_stat_user_tables.n_tup_upd у match_decisions соответствует
реальным сменам решения/forced-rematch, не числу обработанных fallback SKU.
5.2 facts-projector lag растёт монотонно
./scripts/pg_slow_queries.sh active # есть ли blocked queries?
docker logs tracium-facts-projector-1 --tail 100 | grep -E "factsproj:|error"Проверить outbox-курсор:
SELECT last_processed_id, last_processed_at FROM char_facts_projector_cursor;
SELECT max(id) FROM outbox_events WHERE topic = 'matching.char_facts.v1';Если курсор не двигается — projector упал на failed event. factsproj_events_failed_total{event_kind} покажет тип.
Mitigation: рестарт docker compose restart facts-projector. Tight-loop из P-O-7 за один tick обработает ≤10k backlog.
С 2026-07-15 facts-projector не должен морозить live replay на время
исторического seed. При большом backlog это оставляет свежие характеристики
невидимыми для canonical assignment и ES на часы. Правильный порядок старта:
- прочитать pre-seed
max(outbox_events.id)дляmatching.char_facts.v1; - запустить live ticker, чтобы свежий outbox продолжал дрениться;
- параллельно запустить
SeedFromHistory, который строит latest snapshot изoffer_characteristic_raw; - при успешном seed сдвинуть
char_facts_projector_cursorдо pre-seed watermark черезJumpCursorTo;
Если seed падает или истекает FACTS_PROJECTOR_SEED_BUDGET, cursor прыгать не
должен: live ticker обязан продолжить обычный outbox replay со старой позиции.
Если в /queues у FACTS-PROJECTOR Возраст курсора растет, а Последнее событие # не меняется, сначала проверить cursor и активные запросы:
SELECT last_processed_id, last_processed_at, last_error, blocked_outbox_id
FROM char_facts_projector_cursor;
SELECT pid, wait_event_type, wait_event, now() - query_start AS age, left(query, 300)
FROM pg_stat_activity
WHERE query ILIKE '%offer_characteristic_facts%'
OR query ILIKE '%char_facts_projector_cursor%';Если сервис застрял на историческом seed, но нужно срочно разгребать outbox без полной выкладки, временно ограничить seed budget и пересоздать только worker:
/srv/tracium/infra/deploy/production-runtime/bin/restart-db-vps-service-env.sh \
facts-projector FACTS_PROJECTOR_SEED_BUDGET=1sЭто не включает LLM и не чинит category/value workers; оно только возвращает
движение offer_characteristic_facts через outbox replay.
5.3 Matcher matches generic характеристик («Нет», «шт»)
MATCHER_FACTS_RARITY_THRESHOLD управляет компромиссом: чем ниже значение,
тем меньше fanout и IO в Tier-2 facts overlap; чем выше — тем больше generic
характеристик участвуют в matching evidence.
На prod с 2026-07-09 временно стоит 300 вместо прежних 1000, пока DB-VPS
не расширен до 64GB и slowlogs не перебазированы.
При положительном пороге matcher обязан использовать один и тот же ключ для
статистики редкости и overlap: (canonical_key, raw_value_hash). Нельзя
фильтровать редкость по raw_value_hash, а соединять кандидатов по
canonical_value_id: одно редкое написание тогда раскрывается в миллионы
нормализованных строк и фактически отменяет fanout guard. Режим 0 явно
отключает guard и сохраняет cross-supplier overlap по canonical_value_id; он
предназначен для ограниченной калибровки, а не для production sweep.
Если качество matching страдает из-за отсечения generic-пар — поднимать порог
deploy-managed изменением deploy/prod-env-template.env, затем деплой. После
изменения порог применяется к инкрементально поддерживаемой char_fact_stats
без отдельного refresh.
Проверить top-generic:
SELECT canonical_key, doc_count FROM char_fact_stats ORDER BY doc_count DESC LIMIT 20;5.4 /v1/canonical/{id}/analogs → model=facts-text-fallback
Public endpoint не должен ждать полного embedding backfill. Если embedding для
anchor canonical ещё не посчитан, сервис использует no-embedding ranker и
возвращает обычный JSON с model=facts-text-fallback. Admin/explain endpoint
может вернуть 503 Retry-After, если вызывающий слой явно требует embedding
status.
Проверить готовность embedding:
SELECT name_embedding IS NOT NULL AS has_emb,
embedding_generated_at,
embedding_model
FROM canonical_products WHERE id = $1;
SELECT count(*) FROM canonical_embedding_dirty
WHERE canonical_id = $1;Если canonical_id есть в queue и embedding_dirty_queue_depth падает — просто ждать. Если очередь застряла:
docker logs tracium-embedding-worker-1 --tail 100 | grep -E "embedding:|error"Возможные причины:
LLM_MODEL_EMBEDDING=""— service disabled (intentional).WORKER_PAUSED_EMBEDDING_WORKER=true— ticker остановлен намеренно.- LLM gateway возвращает
404на/v1/embeddings— сначала чинить gateway/провайдера, не держать worker в retry-loop. - LLM provider rate-limit / API key invalid —
embedding_failed{reason="provider_error"}↑.
5.5 No-embedding fallback пустой или медленный
Fallback без embeddings не должен сканировать свежие offers по широкой
supplier-native категории. С 0234 hot path берёт кандидатов из
canonical_analog_assignment_lookup: это read-model по текущим
canonical_assignments и canonical_category_assignments, индексированный по
(category_id, characteristic_id, value_hash, canonical_id).
Быстрая проверка read-model:
SELECT reltuples::bigint AS approx_rows
FROM pg_class
WHERE oid = 'canonical_analog_assignment_lookup'::regclass;
SELECT indexrelid::regclass AS index_name, indisvalid, indisready
FROM pg_index
WHERE indexrelid = 'canonical_analog_assignment_lookup_idx'::regclass;
SELECT tgname
FROM pg_trigger
WHERE tgname LIKE 'canonical_analog_assignment_lookup_%'
ORDER BY tgname;Ожидание: таблица непустая, индекс indisvalid=true и indisready=true, есть
два триггера на canonical_assignments и canonical_category_assignments.
Если таблица пустая после миграции, выполнить backfill из миграции 0234 и
VACUUM (ANALYZE) canonical_analog_assignment_lookup.
Если EXPLAIN показывает nested loop без index seek по
canonical_analog_assignment_lookup_idx, проверить что target canonical имеет
canonical_category_assignments и непустые canonical_assignments. Fallback
ограничивает вклад каждой характеристики через LATERAL:
- identity-характеристики (
rated_current, poles, trip curve) ищутся шире,LIMIT 3000, потому что значения вродеCи1Pочень частые иLIMIT 1000может не включить пересечение всех критичных групп; current_typeтоже попадает в широкий lookup pool и остаётся весовым структурным сигналом, но не является hard identity gate: в реальных данныхAC,AC/DCи voltage-derived значения часто отражают supplier-доступность неполно и иначе вырезают коммерчески пригодные модульные автоматы;- остальные характеристики остаются узкими,
LIMIT 80, чтобы не возвращаться к полному скану категории.
Candidate проходит дальше только если совпали все hard identity-группы, которые
есть у target. voltage_type остается весовым сигналом, но не hard gate: в
реальных данных он часто нормализован разными словарными строками и иначе
обнуляет валидные автоматы. Для poles hard gate использует poles_total
(poles / pole_count / total_number_of_poles), а
protected_pole_count и power_pole_count остаются весовыми сигналами: это
отсекает 1P+N при target 1P, но не требует полного набора helper-полей у
каждого поставщика. Text score считается через to_tsvector('russian', cp.name) на лету, потому что на prod canonical_products.name_tsvector может
быть пустым до отдельного embedding/text backfill.
Если canonical-assignment-worker пишет upsert batch failed с
statement timeout, а phase build_decisions завершается быстро, проверить
maintenance-delete read-model. Row-level trigger на canonical_assignments
сначала удаляет stale lookup rows по (canonical_id, characteristic_id), и без
индекса 0235 planner вынужден сканировать большой category-first индекс:
EXPLAIN (COSTS, BUFFERS)
DELETE FROM canonical_analog_assignment_lookup
WHERE canonical_id = '<canonical_uuid>'
AND characteristic_id = '<characteristic_uuid>';
SELECT indexrelid::regclass AS index_name, indisvalid, indisready
FROM pg_index
WHERE indexrelid IN (
'canonical_analog_assignment_lookup_idx'::regclass,
'canonical_analog_assignment_lookup_canonical_char_idx'::regclass
);Ожидание после 0235: canonical_analog_assignment_lookup_canonical_char_idx
есть, indisvalid=true, indisready=true, а EXPLAIN использует этот индекс.
Если индекс отсутствует или invalid, пересоздать его только concurrent-командой
из миграции 0235 и затем ANALYZE canonical_analog_assignment_lookup.
Если direct /v1/canonical/{id}/analogs?limit=5 возвращает JSON 504
примерно за 5s, но сам no-embedding rank SQL в pg_stat_statements укладывается
в 1-4s, проверить post-rank этап LatestFacts. После ranking сервис загружает
latest facts для anchor + candidate canonical IDs и применяет analog rules
(poles, rated_current, breaking_capacity, DC-safety). Этот этап должен
идти через маленький target_offers set и индекс 0237:
SELECT indexrelid::regclass AS index_name, indisvalid, indisready
FROM pg_index
WHERE indexrelid = 'offer_characteristic_facts_offer_key_observed_idx'::regclass;
SELECT calls, mean_exec_time, max_exec_time, left(query, 220)
FROM pg_stat_statements
WHERE query ILIKE '%target_offers AS MATERIALIZED%'
AND query ILIKE '%offer_characteristic_facts%'
ORDER BY max_exec_time DESC
LIMIT 5;Ожидание после 0237: индекс валиден, а LatestFacts не добавляет секунды к
5s hard budget analog endpoint даже во время активного supplier ingestion.
Индекс намеренно не включает raw_value: крупные JSON/text значения могут
превысить btree row limit, поэтому raw_value дочитывается из heap после
быстрого offer-scoped lookup.
5.6 /v1/canonical/{id}/analogs → 504 на тяжёлой категории
Если target embedding уже есть, но category subtree большой, primary
embedding-rank может упереться в edge timeout. API обязан оставить бюджет на
деградацию: после короткого внутреннего таймаута embedding-rank выполняется
fallback на facts/text ranking и возвращается обычный JSON-ответ. В таком
ответе model выставляется в facts-text-fallback, чтобы прод-диагностика не
путала деградацию с полноценным semantic rank.
Если вместо этого клиент всё ещё получает raw 504, проверить активные запросы
и pg_stat_statements по category_candidates, category_recent_offers и
RankWithEmbedding-SQL. Это означает, что slow path съедает весь внешний
request budget до запуска fallback.
Для canonical с несколькими supplier categories anchor выбирается как самая
глубокая и узкая supplier-native category: depth DESC,
category_offer_counts.cnt ASC, затем latest_seen DESC. Это защищает
direct analogs от широких родительских категорий вроде
«Низковольтное и промышленное электрооборудование», где один запрос может
затронуть сотни тысяч offers и проиграть edge timeout.
Если одновременно /v1/products/resolve, /v1/estimates/proposals и direct
analogs деградируют, сначала проверить не hot-path SQL, а background IO:
SELECT now() - query_start AS age, wait_event_type, wait_event, left(query, 220)
FROM pg_stat_activity
WHERE state = 'active'
ORDER BY query_start;Опасные для foreground latency паттерны: autovacuum: VACUUM public.supplier_offers, title-extract-worker subtree scans по
supplier_offers, continuous supplier-sync writes и stock-detail-warmer
detail writes, facts-projector tight-loop по offer_characteristic_raw,
matcher loading_candidates и canonical-assignment-worker bulk writes.
title-extract-worker, stock-detail-warmer, facts-projector, matcher и
canonical assignment не участвуют в синхронном чтении уже построенных
предложений; supplier-sync обновляет ingestion cache, но API продолжает
читать последние cached observations. Для быстрого снятия нагрузки можно
временно выставить:
Production admission для supplier-sync должен оставаться 2/1/1/1:
SUPPLIER_SYNC_MAX_IN_FLIGHT=2, один catalog, один non-catalog и одна job на
supplier_ref. Последний гейт принципиален: Systeme commerce сам проходит
/getdata, поэтому его параллельный запуск с Systeme catalog дублирует полный
provider/DB scan. Ожидаемо максимум два live-lock, без повторяющегося
supplier_ref; прерванные job остаются due и продолжаются после освобождения
слота, а не удаляются.
Для Systeme commerce профиль задаёт max_pages_per_tick=50: это примерно
5 тысяч SKU за короткий тик вместо непрерывного прохода на несколько часов.
После каждой страницы сохраняется page_offset с Done=false, поэтому
следующий тик и перезапуск контейнера продолжают обход с очередной страницы.
У catalog этого cap нет: полный discovery остаётся суточным.
Неизменная карточка supplier_offers на суточном catalog-проходе не должна
переписываться. Catalog liveness heartbeat сохраняется не чаще одного раза в
6 суток — это оставляет сутки до существующего quality stale-порога 7 суток.
В pg_stat_statements устойчивый поток WITH prev ... INSERT INTO supplier_offers с rows ≈ calls на уже заполненном каталоге означает, что
no-op guard не сработал или большинство карточек действительно старше 6 суток.
MinIO raw hot-cleaner не должен постоянно обходить небольшой bucket. В
production RETENTION_S3_RAW_CLEANER_PRESSURE_BYTES=8589934592; ниже 8 GiB в
логах ожидается minio_raw_retention.skip ... reason=below_pressure. При этом
семидневный ILM остаётся активен. Если виден minio_raw_retention.start, сначала
проверьте usage_bytes: полный mc rm --recursive допустим только при
достижении порога, а не как постоянный фон на общем с PostgreSQL NVMe.
Внутренний scanner должен видеть MINIO_SCANNER_SPEED=slowest; это не отключает
ILM/healing, а снижает их приоритет относительно рабочих I/O.
/srv/tracium/infra/deploy/production-runtime/bin/restart-db-vps-service-env.sh \
title-extract-worker WORKER_PAUSED_TITLE_EXTRACT_WORKER=true
/srv/tracium/infra/deploy/production-runtime/bin/restart-db-vps-service-env.sh \
stock-detail-warmer WORKER_PAUSED_STOCK_DETAIL_WARMER=true
/srv/tracium/infra/deploy/production-runtime/bin/restart-db-vps-service-env.sh \
supplier-sync WORKER_PAUSED_SUPPLIER_SYNC=true
/srv/tracium/infra/deploy/production-runtime/bin/restart-db-vps-service-env.sh \
matcher-worker WORKER_PAUSED_MATCHER_WORKER=true
/srv/tracium/infra/deploy/production-runtime/bin/restart-db-vps-service-env.sh \
canonical-assignment-worker WORKER_PAUSED_CANONICAL_ASSIGNMENT_WORKER=true
/srv/tracium/infra/deploy/production-runtime/bin/restart-db-vps-service-env.sh \
facts-projector WORKER_PAUSED_FACTS_PROJECTOR=trueПосле паузы повторить estimate matrix. Baseline ожидание для 16 строк:
/v1/products/resolve < 2s, /v1/estimates/proposals < 5s, без 504 по direct
analogs. Если workers нужны обратно, сначала снизить их batch/interval или
добавить недостающие индексы, затем включать по одному и повторять matrix.
Для facts-projector pause должен останавливать не только регулярные тики,
но и startup self-seed. Быстрая проверка после recreate/deploy:
SELECT count(*) FILTER (WHERE lower(query) LIKE '%offer_characteristic_raw%') AS facts_seed,
count(*) FILTER (WHERE wait_event_type = 'IO') AS io_waits
FROM pg_stat_activity
WHERE state = 'active' AND pid <> pg_backend_pid();Ожидаемый результат при WORKER_PAUSED_FACTS_PROJECTOR=true: facts_seed=0
и отсутствие factsproj: starting self-seed from raw history в свежих логах.
5.7 /v1/products/resolve для сметы отдаёт свежие, но нерелевантные кандидаты
Для free-text строк вроде «Автоматический выключатель 1P C16 6кА» resolver не
должен наполнять пул первыми свежими совпадениями по общим словам
автоматический / выключатель. Phase-2 candidate search сначала делает
bounded-запросы по специфичным токенам (C16, артикулоподобные модели,
электрические значения) вместе с общим текстовым контекстом, и только потом
падает в широкий OR fallback. Это сохраняет supplier_offers_name_trgm_idx
index path и не возвращает последние обновленные, но неподходящие крупные
автоматы или аксессуары.
Для проверки на prod:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id
FROM supplier_offers
WHERE canonical_id IS NOT NULL
AND lower(name) LIKE '%c16%'
AND (lower(name) LIKE '%автоматический%'
OR lower(name) LIKE '%выключатель%')
ORDER BY last_seen_at DESC, supplier, supplier_sku
LIMIT 20;Ожидаемый plan — bitmap через supplier_offers_name_trgm_idx по C16 и
top-N sort на малом candidate set. Если plan снова идет через широкий
last_seen_at scan по общим словам, проверить stats и наличие trigram index.
Pole matching в scorer трактует однополюсный, 1п и модельные формы вроде
1C16 как 1P, но не считает 1P+N точным совпадением 1P; иначе
дифференциальные автоматы могут вытеснить обычные однополюсные кандидаты.
Если у строки несколько разных canonical с одинаковой максимальной confidence
после scoring (например, ВА47-60M 4P 63A без кривой B/C/D), resolver должен
вернуть ambiguous/multiple_top_candidates, а estimate line — needs_review.
Нельзя молча выбирать первый вариант: это дает ложный resolved и может
подставить цену/остаток для неверной модификации.
/v1/products/resolve обрабатывает строки сметы параллельно, поэтому
диагностические источники и тестовые doubles для resolve/proposals не должны
писать в общий state без синхронизации. Иначе CI -race может падать
флейково, а прод-диагностика будет маскировать реальные проблемы качества
аналогов шумом от тестовой инфраструктуры.
5.8 embedding_dirty_queue_depth растёт неконтролируемо
Триггеры наполняют быстрее worker’а. Причины:
LLM_MODEL_EMBEDDING=""— worker no-op’ит.- LLM rate-limit —
embedding_failed{reason="provider_error"}↑. - Партия canonical INSERT/UPDATE из ingestion-tick’а.
Mitigation: повысить MaxBatch в embedding/domain/types.go (default 50). Не повышать выше 100 — LLM-provider может отбрасывать.
5.10 В выдаче аналогов только товары того же бренда
Симптом: у якоря есть кандидаты, но все — того же производителя; замен другой марки нет вовсе.
Причина: ось бренда получила право отсекать кандидатов. Аналог по определению
товар ДРУГОГО производителя, поэтому такое право у брендовых осей отнято
(app.IsCrossBrandIdentityKey: manufacturer, manufacturer_name,
manufacturer_article, brand, trade_mark, series, mpn). Два канала:
- Правило в
category_analog_rulesлюбого вида, кромеdecorative. Авторинг такое предлагать больше не может —ParseResponseотбрасывает предложение до записи, независимо от уверенности. Прод 2026-08-11: погашен 661 накопленный правило (всеllmиcarried), бэкап —analog_rules_brand_axis_backup_20260811; 141 pending-предложение отклонено сreviewer = 'system:cross-brand-guard'. - Роль
identityвcategory_characteristic_significance: попадала в strict-фильтр отбора и отсекала кандидатов до ранжирования (было у 312 категорий поmanufacturerи у 53 поseries).
Диагностика:
SELECT canonical_key, kind, source, count(*)
FROM category_analog_rules
WHERE canonical_key IN ('manufacturer','brand','series','trade_mark','mpn')
AND kind <> 'decorative' AND disabled = false
GROUP BY 1,2,3;Непустой результат = правила проехали мимо валидатора; гасить через
disabled = true, предварительно сняв бэкап.
Строки значимости при этом НЕ правим: роль identity на производителе нужна
product family — линейка всегда принадлежит одному бренду. Фильтр стоит в
путях подбора, а не в данных.
Граница, которую нельзя размывать: запрет касается только подбора аналогов. Идентичность каноника считается по производителю и артикулу (ADR-0070) в matcher, который значимость не читает. Одинаковые по характеристикам товары разных брендов — разные товары и в один каноник не сливаются.
5.11 Категория отдаёт пустую выдачу, хотя правила и товары есть
Симптом: подбор отвечает быстро, кандидаты в каталоге заведомо существуют, но
results пуст.
Сначала отличите три разных случая — они лечатся по-разному:
| признак в артефакте sweep | что это | лечение |
|---|---|---|
rejected_by_rules велик, elapsed_ms мал | пул набран вслепую, контракт режет | перенести вид сравнения в значимость |
path=aborted, elapsed_ms ≈ бюджет | strict не укладывается | удешевить strict, не расширять контракт |
effective_identity_keys пуст, path=fallback | структурного пути нет вовсе | завести оси |
Связка, которую легко упустить. CanTightenCandidateSet() даёт оси право
сужать пул по compare_kind строки значимости; роль identity требуется
только там, где вид сравнения не задан. Но compare_kind не заполняется из
category_analog_rules автоматически — правило hard_divergence может
существовать, а отбор о нём не знает. Тогда пул набирается текстовым путём,
и правило честно режет заведомо негодных кандидатов.
Проверка расхождения:
SELECT r.canonical_key, r.kind, s.role, coalesce(s.compare_kind, '(NULL)')
FROM category_analog_rules r
JOIN categories cat ON cat.id = r.category_id
JOIN characteristics c ON lower(c.code) = r.canonical_key
JOIN category_characteristic_significance s
ON s.category_id = r.category_id AND s.characteristic_id = c.id
WHERE cat.slug = '<slug>' AND r.disabled = false AND r.kind = 'hard_divergence';compare_kind = (NULL) при живом правиле — то самое расхождение. Переносят его
миграции 0359 (шесть категорий) и 0360 (весь каталог); у обеих точечный Down по
маркеру в evidence.
Важно: перенос включает strict-путь, а он дороже. Прод 2026-08-12: на шести целевых категориях 9/18 непустых → 11/18 и обрывов 3 → 1, но на независимой выборке из 12 категорий 27/36 → 26/36, обрывов 0 → 1, p95 215 → 540 мс. Прежде чем расширять контракт, мерьте — иначе меняете пустоту на обрыв.
5.12 Проверить правку подбора без выкладки
analog-quality-sweep -categories <id|название через запятую> идёт тем же
кодом, что полный обход, и запускается локально против прода по read-only DSN.
Цикл проверки гипотезы — минуты.
ssh -f -N -L 15432:192.168.1.85:5432 tracium-db
POSTGRES_DSN='postgres://tracium:<pw>@127.0.0.1:15432/tracium?sslmode=disable&options=-c%20default_transaction_read_only%3Don' \
./analog-quality-sweep -categories "<uuid>,<uuid>" -limit 36 -analogs 10 -out m.jsonlСнимайте замер ДО правки и сравнивайте по трём числам: непустых из общего,
обрывов (path=aborted), p95 elapsed_ms. Бинарь пересобирайте после каждой
правки — иначе меряете старый код.
5.9 EXPLAIN на новый запрос показывает Seq Scan по большой таблице
Проверить ANALYZE:
SELECT relname, last_analyze, last_autoanalyze, n_live_tup
FROM pg_stat_user_tables
WHERE relname = '<table>';Если last_analyze пустой / устаревший — ANALYZE <table>.
Если ANALYZE свежий, но planner всё равно seq-scan’ит — посмотреть pg_stats.correlation. Низкая correlation → bitmap-scan норма.
Если pg_stat_statements показывает реалистично-hot query (>1% общего времени) — добавить индекс. Pattern для новой миграции:
-- +goose NO TRANSACTION
-- +goose Up
SET lock_timeout = '30s';
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_name ON tbl (...);
-- +goose Down
DROP INDEX CONCURRENTLY IF EXISTS idx_name;6. Backfill процедуры
offer_characteristic_facts
cmd/facts-backfill:
# Dry-run — посчитать rows to insert без записи
docker compose exec api-server /usr/local/bin/facts-backfill --dry-run
# Реальный прогон
docker compose exec api-server /usr/local/bin/facts-backfill --statement-timeout=1hПосле — VACUUM ANALYZE offer_characteristic_facts обязательно.
facts-projector self-seed на старте — backfill идемпотентен, безопасно прогонять повторно.
На prod не запускать self-seed вместе с foreground incident mitigation:
WORKER_PAUSED_FACTS_PROJECTOR=true должен оставить service поднятым, но без
чтения offer_characteristic_raw до ручного снятия pause.
Для prod tuning без полного CI/CD можно поменять runtime env и пересоздать
только facts-projector:
/srv/tracium/infra/deploy/production-runtime/bin/restart-db-vps-service-env.sh \
facts-projector FACTS_PROJECTOR_SEED_BUDGET=4hСкрипт редактирует /srv/tracium/infra/deploy/.env, делает backup и запускает
docker compose up -d --no-build --no-deps --force-recreate facts-projector.
Он принимает только не-секретные worker/runtime keys и печатает имена ключей
без значений. Для history seed важные параметры:
FACTS_PROJECTOR_BATCH_SIZE, FACTS_PROJECTOR_MAX_TIGHT_LOOP_ITERATIONS,
FACTS_PROJECTOR_SEED_BUDGET.
canonical embeddings
Backfill идёт через триггеры: при первом запуске worker’а с включённым LLM_MODEL_EMBEDDING он начнёт обрабатывать всё, что насобирали триггеры. Принудительно поставить все canonicals в очередь:
На prod embedding-worker дополнительно закрыт runtime-флагом
WORKER_PAUSED_EMBEDDING_WORKER=true, потому что текущий gateway может
обслуживать chat completions, но должен быть явно проверен на
POST /v1/embeddings перед включением backfill. Если в логах появляется
404 Not Found по embeddings, сразу вернуть
WORKER_PAUSED_EMBEDDING_WORKER=true или остановить service и не тратить
LLM-квоту на повторные неуспешные тики.
INSERT INTO canonical_embedding_dirty (canonical_id)
SELECT id FROM canonical_products
ON CONFLICT (canonical_id) DO NOTHING;После полной обработки (embedding_dirty_queue_depth = 0):
VACUUM ANALYZE canonical_products- Опционально
REINDEX INDEX canonical_products_name_emb_idxс tunedlists(rule of thumb:sqrt(N)).
7. Откат
Откатить matcher facts → legacy
# .env
MATCHER_USE_FACTS_PROJECTION=false
docker compose restart matcher-workerОткат немедленный, legacy candidate_reader идёт прямо в offer_characteristic_raw. facts остаётся, projector продолжает работать впустую — безопасно.
Полный rollback facts-projection
docker compose stop facts-projector
goose -dir backend/migrations -tags integration down # до 0100offer_characteristic_facts + char_facts_projector_cursor + char_fact_stats дропнутся. Не критично — matcher на legacy путях.
Полный rollback pgvector / analogs
docker compose stop embedding-worker
LLM_MODEL_EMBEDDING=""
goose -dir backend/migrations down # до 0108/v1/canonical/{id}/analogs начнёт возвращать 500 (колонка name_embedding нет) — endpoint снять до полного rollback.
8. Метрики observability (OTel → Prometheus)
| Метрика | Тип | Что показывает |
|---|---|---|
facts_projection_lag_seconds | gauge | now() - max(facts.updated_at) |
factsproj_events_processed_total{event_kind} | counter | applied events |
factsproj_events_failed_total{event_kind, reason} | counter | failed events |
embedding_dirty_queue_depth | gauge | pending canonicals |
embedding_batch_claimed_total | counter | rows claimed from queue |
embedding_skipped_unchanged_total | counter | hash-skip (idempotent re-run) |
embedding_processed_total{model} | counter | persisted vectors |
embedding_failed_total{reason} | counter | failures (disabled / provider_error / save_error) |
Prometheus alerts (черновик):
- alert: FactsProjectorLagHigh
expr: facts_projection_lag_seconds > 300
for: 5m
labels:
severity: warning
- alert: EmbeddingQueueGrowing
expr: deriv(embedding_dirty_queue_depth[10m]) > 100
for: 30m
labels:
severity: warningСвязано
- ADR-0054
pgvector для поиска аналогов docs/plans/2026-05-12-17-00-pgvector-analogs-mvp.md(parent spec)docs/plans/2026-05-12-17-25-offer-characteristic-facts.md(pre-step subspec)- Memory:
tracium_facts_projection_runbook(создан этим runbook’ом) - Slow-log script:
scripts/pg_slow_queries.sh - Migrations 0097, 0099–0111
- Commits:
cbcd1a72,899237a8,7e4af2a7,5ba723aa,1d3e6649,b360bb3b