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_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. - Из аутентифицированной admin-сессии вызвать
POST /api/v1/admin/catalog/projector/reindex?mode=full. Не использовать default/mode=hot: он обрабатывает только недавнее hot-окно и не является backfill. Ответ409 Conflictозначает, что full-проход уже выполняется в другой реплике. - Дождаться завершения full-прохода и дренирования очереди
catalog-projector. Full-проход глобально сериализован, не шардируется и останавливается при штатном shutdown сервиса. - Проверить в «Канонических товарах» известную позицию: полный внутренний
артикул, его часть,
3120001142и3-12-0001142,3х2,5и3x2.5, а также односимвольную опечатку в названии. Опечатка допустима только для названия/производителя, не для идентификаторов. PostgreSQL данные,supplier_offersи историяoffer_observationsне удаляются. После увеличения RAM проверитьEXPLAIN/pg_stat_statementsдляReadCanonicalsи вернуть30/2000, если p95 укладывается в 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.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 не перебазированы.
Если качество matching страдает из-за отсечения generic-пар — поднимать порог
deploy-managed изменением deploy/prod-env-template.env, затем деплой. После
обновления char_fact_stats фильтр начнёт работать с новым порогом.
Проверить 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. Для быстрого снятия нагрузки можно
временно выставить:
/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