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
└────────────────────────────┘

2. Feature flags + env vars (всё через config.Config)

EnvDefaultЧто
MATCHER_USE_FACTS_PROJECTIONfalsematcher candidate_reader: legacy ocr scan ↔ facts path
MATCHER_FACTS_RARITY_THRESHOLD1000отсечка doc_count для generic характеристик
LLM_MODEL_EMBEDDING""пусто = embedding-worker no-op; text-embedding-3-small — реальные 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

Прод-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. Поднять embedding-worker с LLM_MODEL_EMBEDDING=text-embedding-3-small.
  6. После полного backfill (или асимптотики embedding_dirty_queue_depth → 0) — /v1/canonical/{id}/analogs готов отдавать 200.

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

Полные результаты 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-06-04 facts-projector не должен ждать replay старого outbox-хвоста перед историческим seed. При большом backlog это оставляет свежие характеристики невидимыми для canonical assignment на часы. Правильный порядок старта:

  1. прочитать pre-seed max(outbox_events.id) для matching.char_facts.v1;
  2. запустить SeedFromHistory, который строит latest snapshot из offer_characteristic_raw;
  3. при успешном seed сдвинуть char_facts_projector_cursor до pre-seed watermark через JumpCursorTo;
  4. запустить live ticker, чтобы он обрабатывал только события после watermark.

Если 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 (default 1000). После REFRESH char_fact_stats (раз/10 мин projector’ом) фильтр обновится.

Проверить top-generic:

SELECT canonical_key, doc_count FROM char_fact_stats ORDER BY doc_count DESC LIMIT 20;

5.4 /v1/canonical/{id}/analogs → 503 Retry-After

Embedding для этого canonical ещё не посчитан. Проверить:

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).
  • LLM provider rate-limit / API key invalid — embedding_failed{reason="provider_error"} ↑.

5.5 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.6 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 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 в очередь:

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