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_seconds
  • factsproj_events_failed_total
  • embedding_dirty_queue_depth
  • pg_stat_statements top-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 и сохраняется как фактический контракт качества до его возможной замены.

  1. Ранкер получает canonical-якорь, его нормализованные характеристики и категорию.
  2. Строгая область кандидатов — категория якоря и члены её category_equivalence-группы. Она не включает произвольные соседние или supplier-native категории.
  3. Для характеристики строятся действующие identity-оси:
    • подтверждённая запись значимости участвует только если прошла CanTightenCandidateSet;
    • к ней добавляется seed из charsemantics: rated_current, группа полюсов и tripping_characteristic/trip_curve;
    • ось без значения у якоря не действует.
  4. Строгий пул требует совпадения всех действующих identity-осей. Если он пуст, ранкер пробует relaxed-путь: он сохраняет те же identity-условия, но может расширить область категориями, содержащими назначения с broad_group_fallback. Это временно допускает товары из неразобранного мешка, но не из чужой разобранной категории.
  5. На кандидаты накладываются 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-пулов, фазовые времена, результат и диагноз. Важно не смешивать результат с диагнозом:

ПолеПримерСмысл
statusperformance_failureЧто произошло с запросом: успешно ли он выполнился в публичном бюджете.
empty_reasonno_effective_identity_keysПочему запрос оказался пустым или дорогим. Не заменяет status.
pool_sizes_unknowntrueДиагностика пула не уложилась в свой короткий срок; это не утверждение, что пул пуст.

Статусы: 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)

EnvDefaultЧто
MATCHER_USE_FACTS_PROJECTIONfalsematcher candidate_reader: legacy ocr scan ↔ facts path
MATCHER_FACTS_RARITY_THRESHOLD300 на 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_WORKERtrue на prodkill-switch ticker’а embedding-worker; держать true, пока gateway не отвечает на POST /v1/embeddings
LLM_BASE_URL / LLM_API_KEY(compose default)shared CLIProxy для chat + embeddings
ANALOGS_SEM_WEIGHT0.6вес косинус-similarity в /analogs
ANALOGS_TEXT_WEIGHT0.2вес tsvector ts_rank
ANALOGS_STRUCT_WEIGHT0.2вес structural overlap через offer_characteristic_facts
ANALOGS_ADMIN_HTTP_TIMEOUT45s на prod временнолимит только explainable admin endpoint; оставляет запас до 60s edge/statement timeout
CATALOG_RECONCILER_BATCH_SIZE100hot reconciler PG/ES hash batch; снижать при ReadCanonicals statement timeout
CATALOG_PROJECTOR_BATCH_SIZE200 на prod временноprojector queue batch; держать 200 до RAM 64GB и проверки p95 ReadCanonicals после LATERAL-фикса; 500 ещё ловил редкие 60s timeout под общей IO-нагрузкой
CATALOG_PROJECTOR_OBSERVATION_WINDOW_DAYS7 на prod временноокно свежести price/stock rollup в ES; держать 7 до расширения DB-VPS RAM до 64GB, затем пересмотреть возврат к 30

Прод-rollout порядок:

  1. Поднять facts-projector (compose service уже есть, default false для matcher).
  2. Подождать пока facts_projection_lag_seconds < 30s стабильно.
  3. Флипнуть MATCHER_USE_FACTS_PROJECTION=true, рестарт matcher-worker.
  4. Через сутки — pg_slow_queries.sh top: убедиться что legacy candidate_reader query ушёл из топа.
  5. Проверить gateway: POST /v1/embeddings должен возвращать не 404. Только после этого выставить WORKER_PAUSED_EMBEDDING_WORKER=false и поднять embedding-worker с LLM_MODEL_EMBEDDING=text-embedding-3-small.
  6. После полного 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 считаются одинаковыми. Оно нужно для поиска внутреннего артикула, его фрагментов и значений с ./,.

Порядок выкладки:

  1. Выложить версию catalog-projector/api-server с mapping. На старте EnsureIndex добавит поле через PUT _mapping; alias или новая версия индекса не нужны.
  2. Подтвердить, что mapping текущего canonical-индекса содержит identifier_index типа keyword.
  3. Из аутентифицированной admin-сессии вызвать POST /api/v1/admin/catalog/projector/reindex?mode=full. Не использовать default/mode=hot: он обрабатывает только недавнее hot-окно и не является backfill. Ответ 409 Conflict означает, что full-проход уже выполняется в другой реплике.
  4. Дождаться завершения full-прохода и дренирования очереди catalog-projector. Full-проход глобально сериализован, не шардируется и останавливается при штатном shutdown сервиса.
  5. Проверить в «Канонических товарах» известную позицию: полный внутренний артикул, его часть, 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:

  1. Поднять в deploy/prod-env-template.env Postgres memory knobs: POSTGRES_SHARED_BUFFERS, POSTGRES_EFFECTIVE_CACHE_SIZE, POSTGRES_SHM_SIZE; POSTGRES_WORK_MEM не повышать глобально без отдельного расчёта по concurrency.
  2. Задеплоить DB-VPS и собрать новый baseline pg_stat_statements минимум за 24 часа.
  3. Проверить ReadCanonicals p95/p99 на текущих batch=200, window=7.
  4. Поэтапно вернуть CATALOG_PROJECTOR_BATCH_SIZE=500, затем 2000; после каждого шага проверить p95/p99 и отсутствие SQLSTATE 57014.
  5. Только после успешного batch-теста вернуть CATALOG_PROJECTOR_OBSERVATION_WINDOW_DAYS=30 и повторить p95/p99 проверку.
  6. Перепроверить matcher slowlogs с MATCHER_FACTS_RARITY_THRESHOLD=300. Если facts overlap больше не доминирует в pg_stat_statements, опционально протестировать прежнее значение 1000 на recall и сравнить query p95/p99 перед тем как оставить его.
  7. Обновить этот 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 counters

Postgres-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ЧтоЭффект
0097pg_stat_statements + canonical_products(lower(name)) + supplier_offers WHERE category_id IS NULLquality.Refresher ↓
0099offer_observations(offer_id) WHERE prices/stock_current <> '{}'dataCollection 419s → 46s
0101offer_characteristic_facts + 3 индексаlatest-snapshot for matcher
0102char_facts_projector_cursorprojector state
0103outbox_events(topic, id)fetch range scan
0104char_fact_stats materialized viewrarity guard
0105offer_observations(offer_id) узкий single-colno_obs Hash Anti Join 25.7s → 2.4s
0106assignment_dispute_queue partial pending/in_progressclaim-path
0107canonical_assignments(canonical_id, decided_at DESC)LATERAL MAX 14.3s → 2.6s
0108offer_characteristic_raw(mapping_id) WHERE raw_value ? ‘unit’unit-upgrade probe
0109vector ext + canonical_products embedding columns + ivfflat + gin + dirty queuepgvector MVP
0110triggers canonical_products.name + canonical_assignments → dirty queueподдержка dirty
0111match_decisions partial (status=conflict / status=active)ListCandidates path
0234canonical_analog_assignment_lookup + triggersbounded no-embedding fallback для analogs
0235canonical_analog_assignment_lookup(canonical_id, characteristic_id)fast trigger maintenance for assignment writes
0237offer_characteristic_facts(offer_id, canonical_key, observed_at DESC) INCLUDE raw hash/mappingpost-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 на часы. Правильный порядок старта:

  1. прочитать pre-seed max(outbox_events.id) для matching.char_facts.v1;
  2. запустить live ticker, чтобы свежий outbox продолжал дрениться;
  3. параллельно запустить SeedFromHistory, который строит latest snapshot из offer_characteristic_raw;
  4. при успешном 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}/analogsmodel=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). Два канала:

  1. Правило в category_analog_rules любого вида, кроме decorative. Авторинг такое предлагать больше не может — ParseResponse отбрасывает предложение до записи, независимо от уверенности. Прод 2026-08-11: погашен 661 накопленный правило (все llm и carried), бэкап — analog_rules_brand_axis_backup_20260811; 141 pending-предложение отклонено с reviewer = 'system:cross-brand-guard'.
  2. Роль 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 с tuned lists (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  # до 0100

offer_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_secondsgaugenow() - max(facts.updated_at)
factsproj_events_processed_total{event_kind}counterapplied events
factsproj_events_failed_total{event_kind, reason}counterfailed events
embedding_dirty_queue_depthgaugepending canonicals
embedding_batch_claimed_totalcounterrows claimed from queue
embedding_skipped_unchanged_totalcounterhash-skip (idempotent re-run)
embedding_processed_total{model}counterpersisted vectors
embedding_failed_total{reason}counterfailures (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