Подбор аналогов — как это реально работает (as-built)

Статус: current (as-built), сверено на commit 57b37013 (2026-08-09). Документ — вводная карта для новых разработчиков: весь путь данных от опроса поставщиков до ранжированного списка аналогов, словарь терминов, технологии каждой стадии и честное состояние «что сделано / что осталось». Операционные детали и диагностика — в runbook matching & analogs search. Целевой (target) дизайн — в сценарии подбора аналогов; при расхождении верен этот документ.

1. Большая картина

Подбор аналогов — не один сервис, а конвейер из восьми стадий. Каждая стадия превращает данные из более сырой формы в более пригодную для сравнения товаров. Качество выдачи определяется самой слабой стадией, а не алгоритмом ранжирования.

flowchart TD
    subgraph S1["1. Сбор данных"]
        SUP["Поставщики: ETM, Russvet,<br/>IEK, Systeme, etm.ru-скрейпер"] --> SS["supplier-sync<br/>(Go-воркер, тикер на поставщика)"]
        SS --> RAW[("supplier_offers,<br/>offer_characteristic_raw<br/>append-only сырьё")]
        SS --> CP[("canonical_products<br/>UUIDv5 от производитель+артикул,<br/>создаёт CanonicalProvisioner")]
    end

    subgraph S2["2. Нормализация характеристик"]
        RAW --> CN["charnorm-worker: имена<br/>(LLM-шлюз)"]
        CN --> CHARS[("characteristics,<br/>char_name_mappings")]
        RAW --> VN["valuenorm: значения<br/>(read-path в ingestion;<br/>закрытый реестр НЕ включён)"]
        VN --> VOC[("canonical_value_registry,<br/>value_spelling_resolution")]
    end

    subgraph S3["3. Проекция фактов"]
        RAW -- "outbox matching.char_facts.v1" --> FP["facts-projector"]
        FP --> FACTS[("offer_characteristic_facts<br/>последний снимок на оффер")]
    end

    subgraph S4["4. Матчинг: решение оффер-канон"]
        FACTS --> MW["matcher-worker<br/>(tier 1-2 правила, tier 3 LLM)"]
        MW --> MD[("match_decisions<br/>exact/strong/probable/weak")]
    end

    subgraph S5["5. Факты на каноне"]
        MD --> CAW["canonical-assignment-worker<br/>(берёт только exact и strong)"]
        FACTS --> CAW
        CAW --> CA[("canonical_assignments,<br/>canonical_analog_assignment_lookup")]
    end

    subgraph S6["6. Значимость и правила категорий"]
        CA --> SW["significance-worker<br/>(формула significance/v3)"]
        SW --> SIG[("category_characteristic_significance")]
        LLMR["LLM-авторинг правил"] --> RULES[("category_analog_rules")]
    end

    subgraph S7["7. Витрина каталога"]
        CA -- "очередь catalog_projector_queue" --> CPJ["catalog-projector"] --> ES[("Elasticsearch<br/>catalog_canonical_v1")]
        CA -- "триггеры dirty-очереди" --> EMB["embedding-worker<br/>(pgvector, НА ПАУЗЕ)"] --> VEC[("canonical_products.name_embedding")]
    end

    subgraph S8["8. Подбор"]
        API["AnalogsService<br/>GET /v1/canonical/:id/analogs<br/>бюджет ответа 5 с"]
    end

    SIG --> API
    RULES --> API
    CA --> API
    CHARS --> API
    VEC -. "выключено: 0 векторов" .-> API

Технологический стек по конвейеру: Go-воркеры (uber/fx, ADR-0030) поверх PostgreSQL 17 (pgx/v5, goose-миграции); события между стадиями — транзакционный outbox в PostgreSQL (не Kafka; Kafka обслуживает другие контуры); LLM-вызовы — через отдельный LLM-шлюз (модели воркеров задаются в БД llm_model_config); семантика — pgvector + ivfflat (выключено); витрина каталога — Elasticsearch. Все воркеры живут на DB-VPS, публичный api-server — на App-VPS.

2. Стадии конвейера: что, куда, чем

#СтадияКомпонентВход → ВыходТехнологии
1Сбор данныхsupplier-sync (backend/cmd/supplier-sync, BC internal/core/ingestion)API поставщика → supplier_offers, offer_observations (цены/остатки), offer_characteristic_raw; сырой JSON — в MinIO/S3. Канон рождается уже здесь: CanonicalProvisioner.EnsureForOffer создаёт canonical_products (UUIDv5 от производитель+артикул)Go-тикеры на поставщика, universal cursor (incremental_sync_cursor), пул кредов из БД
2aНормализация имён характеристикcharnorm-workerсырые имена характеристик → characteristics (реестр, RU-имена) + char_name_mappings (соответствия)LLM-шлюз, справочник в БД
2bНормализация значенийread-path ingestion.ValueNormModule в supplier-sync + valuenorm-worker (НА ПАУЗЕ)старый контур (live, только enum/multi_enum/boolean): словарь characteristic_value_dictionary + очередь characteristic_value_unmappedoffer_characteristic_raw.canonical_value. Новый контур: закрытый реестр canonical_value_registry + value_spelling_resolution по ключу (класс, написание) — резолвер готов, в ingestion НЕ включёнклассы понятий, очередь ревью value_spelling_review, LLM с правом «не знаю»
3Проекция фактовfacts-projector (backend/cmd/facts-projector)outbox matching.char_facts.v1offer_characteristic_facts (последний снимок), char_fact_stats (rarity)транзакционный outbox, tick 5 с
4Матчингmatcher-worker (BC internal/core/matching)факты офферов → match_decisions (решение оффер→канон с confidence exact/strong/probable/weak). Канонов не создаёт — только связываетidentity-ключ (производитель, артикул) по ADR-0070; tier 1 — точные правила, tier 2 — эвристика по фактам, tier 3 — батчевый LLM
5Назначения на канонcanonical-assignment-worker (app/assignment_service.go)факты офферов × match_decisions (только exact/strong) → canonical_assignments + read-model canonical_analog_assignment_lookup (19,7 млн строк); споры — LLM-резолвер + очередь модерациипринцип «обогащение, не перезапись»; sticky-override модератора
6aЗначимостьcharacteristic-significance-workerназначения по категории → category_characteristic_significance (роли significant/decorative, веса)формула significance/v3, выборка по хешу с зерном
6bПравила категорийanalog-rules-classifier (LLM-авторинг)категория → category_analog_rules (kind + params)LLM-шлюз с квотой, авто-применение включено
7aВитринаcatalog-projector + reconcilerканоны + наблюдения → ES catalog_canonical_v1outbox → ES, окно свежести 7 дней
7bСемантикаembedding-worker (НА ПАУЗЕ)имя канона → name_embedding (pgvector)очередь canonical_embedding_dirty (1,8 млн в ожидании)
8ПодборAnalogsService (internal/core/catalog/canonical/app/analogs.go, SQL-ранкер infra/postgres/analogs_repo.go) в api-serverякорь → ранжированный список аналогов с объяснением по осямPostgreSQL serving-путь, бюджет 45 с (ANALOGS_PUBLIC_HTTP_TIMEOUT) — обязан оставаться ниже таймаутов прокси (edge и nginx-prod по 60 с)

Смежный контур: смета (/v1/estimates/jobs) сначала резолвит строку в канон (по supplier_sku / mpn / free-text), затем для строк с низкой уверенностью запрашивает те же аналоги инлайн (лимит 5). Резолв — отдельный от подбора механизм со своими дефектами (см. §6).

3. Как решается «аналог / не аналог»

Действующий serving-путь — PostgreSQL (не Elasticsearch и не embeddings):

flowchart TD
    A["Якорь: канон + категория +<br/>нормализованные факты"] --> B{"Есть действующие<br/>identity-оси?"}
    B -- "нет (сегодня 75% якорей)" --> D["Пустая выдача, диагноз<br/>taxonomy_or_data_gap"]
    B -- да --> C["Строгий пул: категория якоря +<br/>её equivalence-группа,<br/>совпадение ВСЕХ identity-осей"]
    C --> E{Пул пуст?}
    E -- да --> R["Relaxed-путь: те же оси, но<br/>+ категории broad_group_fallback"]
    E -- нет --> F["category_analog_rules:<br/>hard_divergence отсекает,<br/>monotone_ge сравнивает «не хуже»"]
    R --> F
    F --> G["Ранжирование: совпадение фактов +<br/>веса значимости + текст.<br/>Семантический вес = 0 (embeddings выкл.)"]
    G --> H["Выдача с объяснением по осям"]
    D --> X["explainEmpty: диагноз с<br/>потолком 300 мс"]

Ключевые механики:

  • Identity-оси строятся на лету: подтверждённая значимость (только если прошла CanTightenCandidateSet) + предметный seed для электротехники (rated_current, группа полюсов, кривая срабатывания). Ось без значения у якоря не действует — поэтому дефицит фактов напрямую сужает оси.
  • Область кандидатов — категория, не весь каталог. Товар из чужой осмысленно разобранной категории аналогом не станет; неразобранный «мешок» допускается только через relaxed-путь.
  • Правила категорий (category_analog_rules) делятся по kind: жёсткое расхождение (hard_divergence) выкидывает кандидата, критичное совпадение (critical_match) требует равенства, monotone_ge разрешает «не хуже чем», decorative игнорируется.
  • Бюджеты: публичный эндпоинт — 5 с; диагноз пустой выдачи — 300 мс и веер не шире 200 кандидатов на ключ (фиксы 2026-08-09).
  • Семейный путь (product family, ADR-0075/0076/0077) — параллельный механизм отбора по опубликованному профилю осей семьи с вложением интервалов (кандидат допустим, если его интервал покрывает интервал якоря). На проде ВЫКЛЮЧЕН: ANALOGS_FAMILY_PATH_ENABLED=false, профили в черновиках, предел пула 1200 заготовлен по замеру.

4. Словарь терминов

ТерминЧто означает
Оффер (supplier offer)Товарная позиция у конкретного поставщика: имя, артикул, цены, остатки, сырые характеристики. Таблица supplier_offers (1,53 млн).
Канон (canonical product)Наша единица товара, склеенная из офферов разных поставщиков по identity-ключу (производитель, артикул). Таблица canonical_products (1,64 млн, из них ~1,28 млн active).
Якорь (anchor)Канон, для которого ищем аналоги.
ХарактеристикаНормализованное имя свойства (rated_current, poles). Реестр — characteristics (13 060 строк), RU-имена там же.
ФактЗначение характеристики у конкретного оффера в последнем снимке: offer_characteristic_facts (19,8 млн). Сырая история — offer_characteristic_raw (21,4 млн, append-only).
Назначение (assignment)Значение характеристики, поднятое с офферов на канон: canonical_assignments; ручная правка модератора — sticky-override, ingestion её не перетирает.
Identity-осьХарактеристика, по которой кандидат ОБЯЗАН совпасть с якорем, чтобы попасть в пул. Действует только при наличии значения у якоря.
Значимость (significance)Расчётная роль характеристики в категории: significant (влияет на ранжирование) или decorative (шум). Роль identity формула не выставляет — это ручной/авторинговый шаг. Таблица category_characteristic_significance (230 тыс. строк, все auto).
Правило категорииЗапись category_analog_rules: kind + параметры. Kinds: hard_divergence (расхождение → отказ), critical_match (требует равенства), monotone_ge/monotone_le («не хуже чем»), enum_compatible (взаимозаменяемые значения), candidate_only_forbidden (лишняя функция у кандидата → отказ, кейс АВ/АВДТ), decorative. Источники: llm, manual, seed, после переноса — carried. Наследуются вверх по дереву категорий.
Equivalence-группа категорийЯвно объявлённые взаимозаменяемые категории; расширяют область кандидатов.
Broad group fallbackПраво заглянуть в неразобранный «мешок» каталога, когда строгий пул пуст. Не право заглянуть в чужую разобранную категорию.
Product familyРучная группа канонов одной предметной области (пилот — ИБП) с профилем осей для сравнения. Eligibility-ось — ось допуска в пул; interval containment — сравнение «интервал кандидата покрывает интервал якоря».
Concept classКласс понятия значения (voltage_type, mounting_method…): словарь допустимых канонических значений внутри класса. Таблица value_concept_classes + canonical_value_registry.
Разрешение написанияСоответствие «сырое написание → каноническое значение» в ключе (класс, написание): value_spelling_resolution. Неразрешённые написания копятся в value_spelling_review.
Exact / reviewВердикты сравнения по осям семьи: точное совпадение или «нужен взгляд человека» (недостающая ось у пары).
Диагноз (empty_reason)Причина пустой/дорогой выдачи: no_effective_identity_keys, taxonomy_or_data_gap, rule_gap и т.д. Не путать со статусом запроса.
Quality sweepКаталожный замер качества: backend/cmd/analog-quality-sweep детерминированно гоняет тот же AnalogsService по якорям всех категорий, пишет JSONL-артефакты (на DB-VPS в /srv/tracium/quality-sweep/).

5. Состояние: что сделано

2026-08-11 — первый рабочий контур: модульные автоматические выключатели. Доказанный рецепт: разметка эталона (650 пар) → контракт правил v2-lite (10 осей × 4 категории, missing_means_reject per-ось) → identity-оси значимости (28 строк manual_override — первые в базе) → группа эквивалентности листьев → precision@5 93,6 % (LLM-арбитр, критических 0). Попутно закрыты: условный якорь (canonical побеждает при наличии контракта, de499a84), дефект усечения веера identity-осей (b18bb44f), публичная выдача показывает отличия кандидата (77e72985). Бюджет эндпоинта временно 60 с — холодное чтение категории 36,9 с. Тиражирование контура — план docs/superpowers/plans/2026-08-11-analogs-contour-rollout.md.

Замер 2026-08-09 (база до контура)

Первый каталожный замер (92 категории × до 3 якорей) после перф-фиксов:

МетрикаДо (236 якорей)После (92 якоря)
Срывы по бюджету1910
Медиана ответа5003 мс251 мс
p955004 мс699 мс

Закрытые слои (работают на проде):

  • Сбор: 5 коннекторов, 1,53 млн офферов, 21,4 млн сырых характеристик; цены/остатки обновляются live-контуром.
  • Матчинг и склейка: канонов 1,64 млн; дедуп 2,52 млн → 1,33 млн проведён (2026-07-14); identity-ключ по ADR-0070.
  • Факты: проекция живёт, лага нет; read-model для подбора — 19,7 млн строк.
  • Значимость: единый источник включён на запись по всей базе (226+ тыс. вердиктов, 2 215 из 2 243 категорий).
  • Скорость подбора: три перф-дефекта закрыты (расширенная статистика планировщика 0351; предел на диагноз; потолок веера) — медиана 251 мс, 0 срывов.
  • Замер качества: quality-sweep — повторяемый каталожный инструмент с диагнозами вместо ручных кейсов.
  • Механизм словаря значений: схема 0300–0308 на проде, резолвер и очередь ревью готовы, гейты механики закрыты (LLM 0/200 вне реестра, идемпотентность).
  • Семейный путь: код внедрён сквозным (отбор до усечения, вложение интервалов, гейт по потерянным exact), выключен рубильником до готовности данных.

Замер 2026-08-12 (504 на публичном эндпоинте: цена случайных чтений)

Клиентский запрос к якорю модульных автоматов упал в 504. Разбор по слоям: в access-логе edge 504 rt=60.027 urt=60.028, в логе api-server rank_ms=56113, в slow-логе Postgres тот же statement 56111.164 ms.

Прогон одного и того же запросаВремя
Первый (EXPLAIN ANALYZE на проде, дневная нагрузка)61 668 мс
Повтор, тот же план92 мс

Горячий узел один — Index Scan using canonical_assignments_canonical_idx, 468 обходов, read=8552 страницы, I/O Timings: shared read=57155 ms. У каждого seed-кандидата свои ~28 строк назначений разбросаны по ~18 страницам heap, а nested loop читает их строго по одной: prefetch (effective_io_concurrency=256) работает только на bitmap-скане.

Множителя два, и путать их нельзя: число случайных чтений (8552 на запрос) и цена одного чтения. Цену задаёт загрузка диска: под дневной нагрузкой (%wa 51,6, load 36,9, 17 бэкендов в DataFileRead) вышло 6,7 мс на страницу 8 КБ, а в разгруженном окне тот же обход шести категорий дал медиану 246 мс на ответ целиком. Значит 504 — это не «медленный код»: это количество чтений, умноженное на цену чтения в момент запроса.

Первый множитель собирались убрать покрывающим индексом canonical_assignments (canonical_id, characteristic_id) INCLUDE (value) (миграция 0362) — не вышло, и не выйдет: запись btree ограничена третью страницы, прод отверг сборку index row size 2872 exceeds btree version 4 maximum 2704. Чистка данных не спасает — индекс на value сделал бы любую будущую длинную величину отказом на записи, а величины приходят от charnorm и LLM-обогащения. Обойти нечем: pg_column_size не immutable (в предикат частичного индекса не поставить), INCLUDE не принимает выражений (внутри индекса не обрезать и не хешировать). Остаток упавшей сборки — невалидный индекс на 1673 МБ — снят миграцией 0363.

Что реально сделано: выровнены тайминги. Бюджет приложения 60 с совпадал с proxy_read_timeout edge, прокси выигрывал гонку, и клиент получал HTML-заглушку вместо типизированного JSON-504.

Куда идти за первым множителем: у read-model canonical_analog_assignment_lookup есть колонка value_hash (md5, 32 байта) — покрывающий индекс по ней под ограничение записи не попадёт никогда. Требует правки самого запроса strict-ветки и своего замера.

Второй множитель остаётся системным: рабочий набор (read-model 30 ГБ плюс назначения 9,3 ГБ) не влезает в shared_buffers 12 ГБ, а эндпоинт получает порядка одного запроса в сутки — прогретых страниц у него не бывает.

Вывод для замеров: число без указания загрузки диска бессмысленно, сравнивать можно только прогоны в одинаковых условиях.

6. Почему подбор ещё не в рабочем состоянии

Замер 2026-08-09: passed только 2 якоря из 92. Скорость больше не блокер — блокеры в данных и правилах, по слоям:

#БлокерЧислоСлой
0Разрыв деревьев в скоупе подбора (найден 2026-08-10, закрыт УСЛОВНО 2026-08-11 коммитом de499a84): canonical-якорь побеждает supplier-native, когда на канонической категории есть контракт (действующее правило или identity-ось)закрыто для 8 категорий с контрактом (49 084 канона); категории БЕЗ контракта по-прежнему якорятся в native с пустым пулом — лечится тиражированием контрактов, не отдельным фиксомскоуп
1Якорей без единой действующей identity-оси69 из 92 (75%)факты/оси
2Категорий текущего дерева без правил аналогов89 из 92 (правила живут на старом дереве: 15 676 правил на 1 282 категориях supplier-native v1; на действующем canonical v2 — 8 правил на 4 категориях)правила
3Написаний значений без разрешения (очередь ревью)7 244 открытых (6 701 value_missing, 498 conflict, 45 standard_code); характеристик с классом понятия — 74 из 13 060словарь значений
4Embeddings0 из 1,64 млн; очередь dirty 1,8 млн; воркер на паузе (шлюз не отвечает на /v1/embeddings)семантика
5Расщепление каноноводин артикул (C9F34116) живёт в 2+ канонах → две разные выдачи и цены; ~9 153 группы-кандидата на склейку, из них 567 — размерные сетки, которые склеивать нельзясклейка
6Free-text резолвзапрос без артикула садится на соседний тип товара (АВ → АВДТ); категорийный гейт их не разделяетрезолв
7Семья ИБП: члены без фактов1 908 из 3 475 (55%), при этом у 88% лежит непустой сырой ответ поставщика; 1 212 канонов — офферы без категорииобогащение
8Значимость вся auto, роль identity не выставлена нигде987 identity-кандидатов ждут авторингаоси

Причинно-следственная цепочка для презентации: без разрешённых значений и фактов на каноне не строятся identity-оси → без осей пул кандидатов пуст или несравним → без правил категории нечем отсечь функционально непригодное → ранжирование без семантики опирается только на текст и структуру.

7. Что осталось (по плану)

Актуальный план — тиражирование контура: docs/superpowers/plans/2026-08-11-analogs-contour-rollout.md. Кратко: прогон переноса правил (код готов, carried=0 — ни разу не запускался) со sweep-обвязкой → sweep-наблюдаемость категорий якоря → волны identity-осей и контрактов по категориям с precision-гейтом → возврат бюджета вниз → словарь значений, обогащение фактов, embeddings, склейка, мешок кабелей.

Исторический контекст (2026-08-09, до контура) — перенос правил между версиями дерева, docs/superpowers/plans/2026-08-09-analog-rules-carry-forward.md:

  1. Схема: источник carried, таблица analog_rule_coverage_reports (код готов).
  2. Отображение native → canonical категорий и замер его цены.
  3. Решение по ключу (перенести / no-op / удалить / конфликт) — чистый Go.
  4. Врезка переноса в транзакцию публикации дерева.
  5. Покрытие и отчёт по версии дерева.
  6. CLI показа и сверки (analog-rules-carry).
  7. Гейт покрытия в prod-health-check.
  8. Регресс на шаблон сид-миграций (source <> 'manual' затирает перенос).

Ожидание от переноса: 627 населённых категорий с действующими правилами (сейчас 4), 636 native-категорий годны к переносу.

Дальше по очереди (порядок задан заказчиком):

  1. Словарь значений — человеческий шаг: ревью 7 классов и 74 значений, разбор 392 смысловых конфликтов и 6 403 заявок, назначение классов (387 кандидатов, 169 131 употребление). Потом включение резолвера в ingestion и повторный ingestion для проверки устойчивости.
  2. Закрытие дефицита фактов: планировщик family-enrich-plan → очередь canonical_deep_enrichment_jobs (первый проход ограничен 50); снятие паузы title-extract-worker; категоризация 1 212 канонов с офферами без категории.
  3. Авторинг identity-осей и правил: 987 identity-кандидатов из значимости, LLM-авторинг правил на перенесённое дерево.
  4. Включение семейного пути после закрытия дефицита фактов (гейт: доля потерянных exact ≤ 0.10, два прогона подряд).
  5. Embeddings: проверить шлюз на POST /v1/embeddings, снять WORKER_PAUSED_EMBEDDING_WORKER, дождаться разбора очереди 1,8 млн — вернуть семантический вес в ранжирование.
  6. Склейка расщеплённых канонов по критерию «(производитель, артикул) + identity-характеристики» — не раньше, чем оси заработают (иначе схлопнутся размерные сетки).

8. Связанные документы