Подбор аналогов — как это реально работает (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_unmapped → offer_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.v1 → offer_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_v1 | outbox → 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 якоря) |
|---|---|---|
| Срывы по бюджету | 191 | 0 |
| Медиана ответа | 5003 мс | 251 мс |
| p95 | 5004 мс | 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 | словарь значений |
| 4 | Embeddings | 0 из 1,64 млн; очередь dirty 1,8 млн; воркер на паузе (шлюз не отвечает на /v1/embeddings) | семантика |
| 5 | Расщепление канонов | один артикул (C9F34116) живёт в 2+ канонах → две разные выдачи и цены; ~9 153 группы-кандидата на склейку, из них 567 — размерные сетки, которые склеивать нельзя | склейка |
| 6 | Free-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:
Схема: источник(код готов).carried, таблицаanalog_rule_coverage_reports- Отображение native → canonical категорий и замер его цены.
- Решение по ключу (перенести / no-op / удалить / конфликт) — чистый Go.
- Врезка переноса в транзакцию публикации дерева.
- Покрытие и отчёт по версии дерева.
- CLI показа и сверки (
analog-rules-carry). - Гейт покрытия в prod-health-check.
- Регресс на шаблон сид-миграций (
source <> 'manual'затирает перенос).
Ожидание от переноса: 627 населённых категорий с действующими правилами (сейчас 4), 636 native-категорий годны к переносу.
Дальше по очереди (порядок задан заказчиком):
- Словарь значений — человеческий шаг: ревью 7 классов и 74 значений, разбор 392 смысловых конфликтов и 6 403 заявок, назначение классов (387 кандидатов, 169 131 употребление). Потом включение резолвера в ingestion и повторный ingestion для проверки устойчивости.
- Закрытие дефицита фактов: планировщик
family-enrich-plan→ очередьcanonical_deep_enrichment_jobs(первый проход ограничен 50); снятие паузыtitle-extract-worker; категоризация 1 212 канонов с офферами без категории. - Авторинг identity-осей и правил: 987 identity-кандидатов из значимости, LLM-авторинг правил на перенесённое дерево.
- Включение семейного пути после закрытия дефицита фактов (гейт: доля потерянных exact ≤ 0.10, два прогона подряд).
- Embeddings: проверить шлюз на
POST /v1/embeddings, снятьWORKER_PAUSED_EMBEDDING_WORKER, дождаться разбора очереди 1,8 млн — вернуть семантический вес в ранжирование. - Склейка расщеплённых канонов по критерию «(производитель, артикул) + identity-характеристики» — не раньше, чем оси заработают (иначе схлопнутся размерные сетки).
8. Связанные документы
- Runbook: matching & analogs search — операционка serving-пути, флаги, симптомы.
- Runbook: пилот product family (ИБП) — порядок работ и стоп-условия семейного пути.
- Сценарий: подбор аналогов — целевой дизайн (ES-поиск).
- Глоссарий — общие термины домена.
- ADR-0054 (pgvector), ADR-0070 (identity-ключ матчинга), ADR-0075/0076/0077 (product family).
- Планы:
docs/superpowers/plans/2026-08-09-analog-rules-carry-forward.md,2026-08-07-value-vocabulary-normalization.md,2026-08-07-product-family-pilot.md,2026-08-07-ups-data-repair.md.