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
└────────────────────────────┘
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 | 1000 | отсечка 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_WEIGHT | 0.6 | вес косинус-similarity в /analogs |
ANALOGS_TEXT_WEIGHT | 0.2 | вес tsvector ts_rank |
ANALOGS_STRUCT_WEIGHT | 0.2 | вес structural overlap через offer_characteristic_facts |
Прод-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 ушёл из топа. - Поднять
embedding-workerсLLM_MODEL_EMBEDDING=text-embedding-3-small. - После полного 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 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 |
Полные результаты 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 на часы. Правильный порядок
старта:
- прочитать pre-seed
max(outbox_events.id)дляmatching.char_facts.v1; - запустить
SeedFromHistory, который строит latest snapshot изoffer_characteristic_raw; - при успешном seed сдвинуть
char_facts_projector_cursorдо pre-seed watermark черезJumpCursorTo; - запустить 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с 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