Сценарий: подбор аналогов

NOTE

Статус: Target design. Документ описывает целевую доменную модель. Соответствующий код реализован частично (см. backend/internal/core/) или пока не начат. Правила маркировки — в 50-processes/documentation-standard.md.

Триггер

  • Customer ввёл текст / артикул / фасеты / условие «не хуже чем» через UI или API.
  • Estimate orchestrator вызывает Search для строки сметы.
  • Admin запустил _explain для отладки.

Участники

BCРоль
SearchOwner. Выполняет запрос к индексам, ранжирует, формирует объяснение.
CatalogИсточник canonical_index (через PL события).
OffersBoost-поля (наличие, last_seen_at).
EnrichmentEmbeddings для семантического поиска.
VisibilityFilter perm.
CustomerSubject (SessionContext).
EstimateВозможный caller.

Sequence diagram

sequenceDiagram
    autonumber
    participant U as 🟫 Customer / Estimate
    participant S as Search
    participant V as Visibility
    participant ES as Elasticsearch
    participant CAT as Catalog (через index)
    participant OFF as Offers (через index)
    participant EN as Enrichment (embeddings)

    U->>S: 🟦 Search(query, mode, subject)
    S->>V: GetCompiledPolicy(subject, target=catalog)
    V-->>S: 🟩 CompiledPolicy
    Note over S: build ES query: BM25 + filters + dense_vector
    S->>ES: query (включая Visibility filter)
    ES-->>S: hits[]
    Note over S: ranking weights apply
    S-->>U: 🟧 SearchExecuted + 🟩 SearchResult{hits, explain}

    alt mode = аналоги
        U->>S: 🟦 FindAnalogs(canonical_id, allowances)
        S->>S: get identity_profile
        S->>ES: query: same profile + critical attrs in tolerance
        ES-->>S: hits
        S-->>U: 🟧 AnalogsRequested + 🟩 SearchResult
    end

Шаги

  1. Customer вводит запрос. Один из режимов:
    • Свободный текст: "насос для горячей воды, 3 м³/ч".
    • По артикулу: "ET054487".
    • Фасетная фильтрация: voltage = 220V AND ip_rating = IP67.
    • Подбор аналогов: analog of canonical_id, allow ±10% по voltage.
    • «Не хуже чем»: ip_rating ≥ IP54, current ≥ 25A.
    • Семантический: задействуется автоматически при отсутствии точного совпадения.
  2. Search получает Subject из SessionContext (Shared Kernel от Customer).
  3. Visibility lookup: GetCompiledPolicy(subject, target=catalog) — получает фильтр / transform spec.
  4. Build ES query:
    • BM25 по name / description / manufacturer.aliases.
    • Boost для exact match по mpn / supplier_sku.
    • Filter из Visibility (если транслируется в ES query — иначе post-filter).
    • Если parsed числовые — фильтр по characteristics.
    • Если есть embedding запроса — hybrid (cosine на dense_vector field).
  5. Execute, получить hits.
  6. Apply ranking weights (если post-rerank).
  7. Apply transform (Visibility) — например, redact полей, price_to_range.
  8. Compose explain (для админ UI / debugging) — какие boost, какие filter.
  9. Return SearchResult с hits[] (canonical / offer + score + explain) + SearchExecuted событие для аналитики.

Режим: «не хуже чем»

Использует BetterDirection каждой числовой Characteristic:

  • higher — больше = лучше (мощность, IP rating). Условие: value >= threshold.
  • lower — меньше = лучше (вес, сопротивление при равных условиях). Условие: value <= threshold.
  • neutral — equal. Условие: value == target (или в tolerance).

Алгоритм:

  1. Берём identity_profile исходного canonical (если задан).
  2. Для каждого critical_attribute_key — определяем BetterDirection.
  3. Применяем фильтры в ES query.
  4. Ранжируем по сумме «отрыва» (нормированному).

Режим: аналоги

1. Получаем identity_profile (текущего canonical).
2. Берём critical_attribute_keys.
3. Для каждого допустимое расхождение (по умолчанию 0; параметр allowances).
4. Query: same identity_profile + critical attrs ∈ tolerance.
5. Из equivalence_classes: members одного класса (если уже сформированы).

Ранжирование (свободный текст)

Порядок:

  1. Exact MPN / supplier_sku (boost).
  2. BM25 по name + description.
  3. Близость числовых характеристик (если parsed из запроса).
  4. Наличие у поставщиков (есть остаток).
  5. Свежесть (last_seen_at).

Веса в ../../20-architecture/schemas/elasticsearch/. Изменения через PR.

Decision points

  • Поиск дал 0 → fallback на семантический.
  • Семантический дал что-то с embedding similarity < threshold → возвращаем с пометкой «возможно неточно».
  • Поиск с pricing_mode=on_request → фильтруется по умолчанию; явная опция include_on_request.
  • discontinued → показывается с пометкой и ссылкой на replaced_by.
  • match_confidence=weak → показывается с пометкой «не проверено».

Edge cases

СлучайПоведение
Запрос содержит брендовое имя как обычное словоЭвристика: проверка через manufacturer aliases.
Visibility deny отфильтровал все результатыВозвращаем пустой результат без объяснения «у вас нет доступа». UX hint: «Попробуйте уточнить запрос». (Privacy: не сообщаем о существовании скрытых данных.)
Embedding для запроса не построен (нет модели)Hybrid режим деградирует до BM25.
Запрос спамит (>10/sec на одного customer)Throttle на API gateway, не на Search.
Отсутствуют indexes (cold start)Search возвращает 503 + alert; не Catalog’у виноват.

Инварианты сценария

  1. Search eventually consistent: между событием Catalog’а и появлением в индексе допускается задержка (целевая p95 < 5 сек).
  2. Visibility применяется перед ранжированием (не post).
  3. Discontinued / weak показываются с явной маркировкой; не скрываются.
  4. Privacy: «нулевой результат» не отличается от «отфильтровано Visibility» (с точки зрения user-visible).
  5. Explain доступен только админам (Visibility role-based).

Метрики и observability

  • search_requests_total{mode} — counter.
  • search_latency_seconds{mode} — histogram.
  • search_zero_results_rate{mode} — alert если spike.
  • search_visibility_filter_overhead_ms — p95.
  • search_index_lag_seconds — задержка от события Catalog до появления в индексе.
  • analog_query_overlap_with_equivalence_classes_ratio — для understanding качества.

Связанные файлы