Сценарий: подбор аналогов
NOTE
Статус: Target design. Документ описывает целевую доменную модель. Соответствующий код реализован частично (см.
backend/internal/core/) или пока не начат. Правила маркировки — в50-processes/documentation-standard.md.
Триггер
- Customer ввёл текст / артикул / фасеты / условие «не хуже чем» через UI или API.
- Estimate orchestrator вызывает Search для строки сметы.
- Admin запустил
_explainдля отладки.
Участники
| BC | Роль |
|---|---|
| Search | Owner. Выполняет запрос к индексам, ранжирует, формирует объяснение. |
| Catalog | Источник canonical_index (через PL события). |
| Offers | Boost-поля (наличие, last_seen_at). |
| Enrichment | Embeddings для семантического поиска. |
| Visibility | Filter perm. |
| Customer | Subject (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
Шаги
- Customer вводит запрос. Один из режимов:
- Свободный текст:
"насос для горячей воды, 3 м³/ч". - По артикулу:
"ET054487". - Фасетная фильтрация:
voltage = 220V AND ip_rating = IP67. - Подбор аналогов:
analog of canonical_id, allow ±10% по voltage. - «Не хуже чем»:
ip_rating ≥ IP54, current ≥ 25A. - Семантический: задействуется автоматически при отсутствии точного совпадения.
- Свободный текст:
- Search получает Subject из
SessionContext(Shared Kernel от Customer). - Visibility lookup:
GetCompiledPolicy(subject, target=catalog)— получает фильтр / transform spec. - 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).
- BM25 по
- Execute, получить hits.
- Apply ranking weights (если post-rerank).
- Apply transform (Visibility) — например, redact полей, price_to_range.
- Compose explain (для админ UI / debugging) — какие boost, какие filter.
- Return
SearchResultсhits[](canonical / offer + score + explain) +SearchExecutedсобытие для аналитики.
Режим: «не хуже чем»
Использует BetterDirection каждой числовой Characteristic:
higher— больше = лучше (мощность, IP rating). Условие:value >= threshold.lower— меньше = лучше (вес, сопротивление при равных условиях). Условие:value <= threshold.neutral— equal. Условие:value == target(или в tolerance).
Алгоритм:
- Берём identity_profile исходного canonical (если задан).
- Для каждого
critical_attribute_key— определяемBetterDirection. - Применяем фильтры в ES query.
- Ранжируем по сумме «отрыва» (нормированному).
Режим: аналоги
1. Получаем identity_profile (текущего canonical).
2. Берём critical_attribute_keys.
3. Для каждого допустимое расхождение (по умолчанию 0; параметр allowances).
4. Query: same identity_profile + critical attrs ∈ tolerance.
5. Из equivalence_classes: members одного класса (если уже сформированы).
Ранжирование (свободный текст)
Порядок:
- Exact MPN / supplier_sku (boost).
- BM25 по name + description.
- Близость числовых характеристик (если parsed из запроса).
- Наличие у поставщиков (есть остаток).
- Свежесть (
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’у виноват. |
Инварианты сценария
- Search eventually consistent: между событием Catalog’а и появлением в индексе допускается задержка (целевая p95 < 5 сек).
- Visibility применяется перед ранжированием (не post).
- Discontinued / weak показываются с явной маркировкой; не скрываются.
- Privacy: «нулевой результат» не отличается от «отфильтровано Visibility» (с точки зрения user-visible).
- 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 качества.
Связанные файлы
- Контексты:
../contexts/search.md,../contexts/catalog.md,../contexts/enrichment.md,../contexts/visibility.md,../contexts/offers.md. - Deep-dive:
../product-identity.md(equivalence),../characteristics.md. - Архитектура:
../../20-architecture/schemas/elasticsearch/(TBD).