# Offline Product Matcher Детерминированный HTTP-сервис для сопоставления живых сообщений покупателей с товарами из `catalog_excel.csv`. Внутри сервиса **нет LLM, BERT, embedding-моделей, внешних API, сетевых запросов и ключей**. Поиск построен на нормализации текста, доменных алиасах, BM25, символьных n-граммах, Damerau–Levenshtein и строгой проверке характеристик товара. ## 1. Как запустить Требуется Python 3.12. Один раз установить зависимости: ```bash python -m pip install -r requirements.txt ``` После этого сервис поднимается одной командой: ```bash python -m app ``` Сервис слушает `0.0.0.0:8000` и предоставляет только один endpoint: ```text POST /match ``` Пример запроса: ```bash curl -X POST http://127.0.0.1:8000/match \ -H "Content-Type: application/json" \ -d '{ "messages": [ "дрель ударная prowerk pw-750 в наличии?", "нужен кабель", "ушм на 150" ] }' ``` Пример ответа: ```json { "results": [ { "message": "дрель ударная prowerk pw-750 в наличии?", "status": "matched", "candidates": [ { "sku": "INS-0008", "confidence": 0.9489 } ] }, { "message": "нужен кабель", "status": "ambiguous", "candidates": [ { "sku": "KAB-0004", "confidence": 0.7843 }, { "sku": "KAB-0005", "confidence": 0.7723 }, { "sku": "KAB-0008", "confidence": 0.7603 } ] }, { "message": "ушм на 150", "status": "not_found", "candidates": [] } ] } ``` Путь к каталогу можно переопределить переменной окружения: ```bash CATALOG_PATH=/path/to/catalog.csv python -m app ``` На Windows PowerShell: ```powershell $env:CATALOG_PATH="C:\path\to\catalog.csv"; python -m app ``` ## 2. Какие решения приняты и почему ### Основная цель Ключевое требование задания — минимизировать ложные ответы. Поэтому решение построено в режиме **precision-first**: - явное противоречие характеристик всегда исключает товар; - недостаточно конкретный товарный запрос возвращает `ambiguous`; - сервис не пытается обязательно выбрать ближайший SKU; - `matched` появляется только когда после проверок остаётся один совместимый товар. Например, для запроса `бита T50` нельзя вернуть `Бита T30`, а для `кабель ВВГнг 4х2.5` нельзя выбрать соседний `3х2.5` или `5х2.5`, даже если названия текстово очень похожи. ### Пайплайн поиска ```text исходное сообщение ↓ Unicode- и текстовая нормализация ↓ поиск товарного типа и доменных алиасов ↓ восстановление опечаток ↓ вывод типа по триграммам, если алиаса нет ↓ лексическое ранжирование кандидатов ↓ извлечение типизированных характеристик ↓ жёсткое удаление несовместимых SKU ↓ matched / ambiguous / not_found ``` ### Нормализация До поиска выполняются: - Unicode NFKC; - приведение к нижнему регистру; - `ё → е`; - `3,5 → 3.5`; - `×`, `х`, `x`, `*` между числами приводятся к `x`; - нормализация тире, пунктуации и пробелов; - канонизация технических обозначений `М10 → m10`, `Р120 → p120`. Русская буква `х` заменяется только между числами. Глобальная замена испортила бы обычные слова вроде `хомут` и `находится`. ### Алиасы товарных типов Алиасы хранятся как каноническое понятие и массив вариантов: ```python PRODUCT_ALIASES = [ { "canonical": "ушм", "aliases": [ "ушм", "болгарка", "углошлифовальная машина", "угловая шлифмашина", "угловая шлифовальная машина", ], }, ] ``` При создании matcher строится обратный индекс: ```python alias_to_canonical[normalize_text(alias)] = canonical ``` Это словарь предметной области, а не ручное сопоставление пользовательских фраз с конкретными SKU. Алиас определяет только тип товара. Конкретная позиция всё равно проходит поиск и проверку характеристик. Длинное точное выражение имеет приоритет над коротким. Поэтому: - `круг зачистной` определяется как `диск`; - просто `круг` определяется как шлифовальный круг; - `дрель-шуруповерт` определяется как `шуруповерт`, а не как обычная дрель; - `пластиковые хомуты` определяются как нейлоновые стяжки, а обычный `хомут` — как червячный хомут. ### Опечатки Для опечаток применяется не одно сравнение всей строки, а несколько уровней: 1. точный нормализованный alias; 2. точное совпадение слов и многословных выражений; 3. символьные триграммы для получения похожих терминов; 4. unrestricted Damerau–Levenshtein для вставок, удалений, замен и перестановок соседних букв; 5. LCS как слабый дополнительный сигнал для сильно повреждённых длинных слов. Например: ```text шуруповерт шурпуоверт ``` отличаются одной соседней перестановкой. А повреждённое `шураыввавёрт` всё ещё может восстановиться до категории `шуруповерт`, но низкая уверенность в категории не превращает один из нескольких шуруповёртов в ложный `matched`. ### Лексический fallback и ранжирование Если алиас товарного типа не найден, триграммы собирают короткий список ближайших строк каталога. Тип выводится только когда три лучшие строки относятся к одному типу и имеют общее слово, которое точно, по префиксу или с малой опечаткой совпало со словом запроса. Поэтому `удар` находит `ударная дрель`, а `аквариум 125 мм` не выбирает УШМ только из-за числа `125`. После определения товарного типа поиск ограничивается соответствующей частью каталога. Внутри неё кандидаты получают совокупный лексический score: - BM25 по токенам; - Dice similarity по символьным триграммам; - выравнивание слов с Damerau–Levenshtein; - покрытие точных токенов запроса. BM25 и n-граммы отвечают за выбор и порядок кандидатов, но не имеют права отменять конфликт размеров или модели. ### Структурные характеристики Regex применяется там, где он действительно уместен: для формальных технических обозначений. Из запроса и карточек товара извлекаются: - размеры: `4.2х75`, `20х20х1.5`; - резьба: `M10`, `M16`; - профили бит: `PH2`, `PZ2`, `T30`, `SL5.5`; - зернистость: `P120`; - напряжение и мощность: `12 В`, `750 Вт`; - количество зубьев; - фасовка и единица продажи; - бренд и модель: `Prowerk PW-750`; - SDS-тип; - различающие варианты товара: по дереву, по металлу, ГКЛ, отрезной, зачистной, пильный и так далее. Явные характеристики являются hard constraints: ```text T50 != T30 M16 != M12 4х2.5 != 3х2.5 150 мм != 125 мм ``` Частичная спецификация допускается. Запрос `труба 20х20` совместим и с `20х20х1.5`, и с `20х20х2`, поэтому результат будет `ambiguous`. Запрос `труба 20х20 стенка полтора` оставляет один SKU. Для модельного кода можно опустить только конечную букву: `АРС-12` совпадает с `АРС-12Л`. Цифровая часть остаётся строгой, поэтому `АРС-12` не может совпасть с другой моделью `АРС-125`. ### Запросы без характеристик не выбрасываются Если товарная категория понятна, сообщение считается товарным даже без размера или модели: ```text нужен кабель дайте дюбелей сверло нужно какие есть диски перфоратор посоветуйте ``` Такие запросы возвращают `ambiguous` и до трёх вариантов. Логика статусов: ```text товарный тип не определён → not_found товарный тип определён, совместимых SKU несколько → ambiguous после жёстких проверок остался один SKU → matched товарный тип понятен, но запрошенной комбинации характеристик нет → not_found ``` ### Нетоварные сообщения Приветствия и слова `подскажите`, `дайте`, `нужно` заранее не удаляются и не считаются признаком мусора. Сначала всегда ищется товарный evidence. Поэтому: ```text статус заказа 4512 и еще нужен бур 8х160 ``` корректно находит бур. Только если товарных признаков нет, дополнительный словарь фраз распознаёт обращения про режим работы, оплату, адрес и статус заказа. Незнакомый нетоварный текст и известный сервисный intent по контракту одинаково возвращают `not_found`. ### Цена и рекомендации Слова `дешевле`, `недорогой`, `бюджетный` не являются идентификатором SKU. Они влияют на сортировку совместимых кандидатов по цене, но результат остаётся `ambiguous`, если объективно подходят несколько товаров. Например: ```text шуруповерт как у макиты, только дешевле ``` `Makita` распознаётся как референсный бренд, а не как обязательный фильтр. Сервис показывает несколько более дешёвых совместимых шуруповёртов и не делает вид, что один из них является единственно правильным. ### Confidence `confidence` — детерминированная оценка ранжирования, а не статистически откалиброванная вероятность. Она учитывает: - уверенность распознавания категории; - лексический score; - совпадение явных характеристик; - количество совместимых SKU; - полноту запроса. Для production число необходимо калибровать на размеченной истории запросов. ### Что было рассмотрено и отброшено **Только fuzzy matching всей строки.** Быстро даёт опасные ответы на соседних размерах и моделях. Он оставлен только как один из сигналов для опечаток. **Только BM25 или character n-grams.** Хорошо получают кандидатов, но всегда находят ближайший товар и не понимают, что отличие `T50/T30` является запретом. **Embedding, BERT, Cross-Encoder и локальная LLM.** В runtime не используются. По уточнённому ограничению тестового любые модели запрещены. Кроме того, семантически близкий товар может быть несовместимым SKU, поэтому даже с моделью потребовалась бы та же проверка характеристик. **Elasticsearch.** Для 466 строк это отдельный тяжёлый сервис без практического выигрыша. Текущий каталог загружается в память при старте. **База данных, Docker, авторизация и frontend.** Осознанно не сделаны, потому что прямо не требуются заданием. ### Что доделать до production - собрать размеченный набор реальных запросов и hard-negative примеров; - считать отдельно precision для `matched`, recall@3 и число ложных `matched`; - откалибровать thresholds и confidence по товарным группам; - вынести алиасы и правила характеристик в версионируемую конфигурацию; - добавить управление алиасами без релиза приложения; - расширить разбор единиц, диапазонов, цветов и сравнительных запросов; - добавить транслитерацию брендов и контролируемую морфологию; - логировать причину `not_found` и отфильтрованные конфликты без изменения публичного JSON; - добавить метрики, трассировку, rate limiting и ограничение размера batch; - собирать исправления операторов и регулярно прогонять regression suite. ## 3. Масштаб: 2 миллиона SKU, PostgreSQL и 50 магазинов Текущая реализация специально рассчитана на каталог около 500 строк. Для двух миллионов товаров нельзя проходить Python-циклом по всему каталогу и нельзя держать отдельную полную копию каждого магазина в каждом worker. ### Схема данных Товары хранились бы в PostgreSQL примерно с такими полями: ```text store_id sku name normalized_name product_type brand model_code unit price parsed_attributes catalog_version ``` Часто используемые характеристики лучше хранить отдельными типизированными колонками, а редкие — в `JSONB`: ```text diameter_mm length_mm thread_size voltage_v grit profile cores section_mm2 ``` ### Tenant context Формат тела `/match` менять нельзя, поэтому `store_id` передавался бы вне JSON: - в path; - в hostname; - в HTTP header; - либо извлекался бы из контекста авторизации. Matcher должен получать уже определённый `store_id` и никогда не смешивать каталоги магазинов. ### Индексы PostgreSQL Минимальный набор: - B-tree по `(store_id, product_type)`; - уникальный индекс по `(store_id, sku)`; - индексы по `(store_id, brand, model_code)`; - составные/частичные индексы по популярным характеристикам; - GIN/GiST trigram index по `normalized_name`; - при необходимости PostgreSQL Full Text Search для словного retrieval. ### Production-пайплайн ```text разобрать запрос в приложении ↓ определить store_id и product_type ↓ применить точные SQL-фильтры по характеристикам ↓ получить 20–100 кандидатов через trigram/FTS ↓ переранжировать небольшой набор в Python ↓ выполнить тот же validation и decision layer ``` То есть BM25/n-gram retrieval переезжает в индексируемый слой данных, но правила совместимости и логика `matched / ambiguous / not_found` сохраняются. ### Обновление каталогов Нужны: - версия каталога на магазин; - пакетный импорт во временную таблицу; - валидация и атомарное переключение версии; - инкрементальные обновления цен и остатков; - фоновые перестроения индексов; - защита от частично загруженного каталога; - кэш с ключом `(store_id, catalog_version, normalized_query)`. ### Разделение нагрузки В зависимости от профиля нагрузки: - partitioning таблиц по `store_id` или hash(store_id); - read replicas для поисковых запросов; - локальный кэш горячих алиасов и метаданных категорий; - ограничение количества кандидатов на уровне SQL; - несколько stateless FastAPI workers; - отдельная очередь для импорта каталогов. Elasticsearch или другой поисковый кластер имеет смысл подключать только после измерений, если PostgreSQL с `pg_trgm`, FTS и структурными фильтрами не обеспечивает нужное качество или throughput. Он заменит candidate retrieval, но не validation layer. ## Тесты Запуск: ```bash python -m pytest -q ``` Набор тестов покрывает: - точные совпадения; - алиасы и разговорные названия; - Damerau–Levenshtein и тяжёлые опечатки; - вывод товарного типа по одному слову и шумным фразам; - безопасное дополнение модели без конечной буквы (`АРС-12` → `АРС-12Л`); - разные записи размеров; - отсутствующие размеры, профили и резьбы; - неоднозначные фасовки; - широкие запросы без характеристик; - нетоварные сообщения; - приоритет товарных признаков над сервисными словами; - исходный порядок сообщений; - максимум три кандидата; - диапазон confidence; - строгий формат request/response; - единственный endpoint `/match`; - self-retrieval всех позиций каталога по полному названию; - поиск по каждому каноническому типу, бренду и модельному коду; - exact-only поиск без названия товара по размерам и структурным атрибутам. `tests/test_message_corpus.py` автоматически читает все непустые строки из `messages.txt` и `more_messages.txt`, прогоняет их через matcher и строго сравнивает статус и SKU с таблицей `EXPECTED_RESULTS`. Ключом служит сам текст сообщения, поэтому добавление строки не сдвигает остальные ожидания: ```python "бур sds 10х210": ("matched", ("BIT-0080",)), "диск": ("ambiguous", ("DSK-0011", "DSK-0012", "DSK-0013")), ``` Для `ambiguous` SKU перечисляются в ожидаемом порядке. Confidence намеренно не фиксируется точным числом: тест проверяет только допустимый диапазон, чтобы изменение формулы оценки не ломало проверенные статус и состав кандидатов. ## Структура ```text app/ ├── __main__.py ├── aliases.py ├── attributes.py ├── catalog.py ├── intents.py ├── main.py ├── matcher.py ├── models.py ├── normalization.py ├── search_index.py └── typo.py tests/ ├── conftest.py ├── test_api.py ├── test_message_corpus.py ├── test_matcher.py └── test_normalization.py ``` ## Git В архив включён локальный Git-репозиторий с веткой `main`, осмысленной историей без Conventional Commits-префиксов и настроенным remote: ```text origin https://git.mcbcorp.ru/Fiden/test-search-engine.git ``` Отправка выполняется на машине с настроенным SSH/HTTP-доступом к Gitea: ```bash git push -u origin main ```