Offline Product Matcher
Детерминированный HTTP-сервис для сопоставления живых сообщений покупателей с товарами из catalog_excel.csv.
Внутри сервиса нет LLM, BERT, embedding-моделей, внешних API, сетевых запросов и ключей. Поиск построен на нормализации текста, доменных алиасах, BM25, символьных n-граммах, Damerau–Levenshtein и строгой проверке характеристик товара.
1. Как запустить
Требуется Python 3.12.
Один раз установить зависимости:
python -m pip install -r requirements.txt
После этого сервис поднимается одной командой:
python -m app
Сервис слушает 0.0.0.0:8000 и предоставляет только один endpoint:
POST /match
Пример запроса:
curl -X POST http://127.0.0.1:8000/match \
-H "Content-Type: application/json" \
-d '{
"messages": [
"дрель ударная prowerk pw-750 в наличии?",
"нужен кабель",
"ушм на 150"
]
}'
Пример ответа:
{
"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": []
}
]
}
Путь к каталогу можно переопределить переменной окружения:
CATALOG_PATH=/path/to/catalog.csv python -m app
На Windows 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, даже если названия текстово очень похожи.
Пайплайн поиска
исходное сообщение
↓
Unicode- и текстовая нормализация
↓
поиск товарного типа и доменных алиасов
↓
восстановление опечаток
↓
вывод типа по триграммам, если алиаса нет
↓
лексическое ранжирование кандидатов
↓
извлечение типизированных характеристик
↓
жёсткое удаление несовместимых SKU
↓
matched / ambiguous / not_found
Нормализация
До поиска выполняются:
- Unicode NFKC;
- приведение к нижнему регистру;
ё → е;3,5 → 3.5;×,х,x,*между числами приводятся кx;- нормализация тире, пунктуации и пробелов;
- канонизация технических обозначений
М10 → m10,Р120 → p120.
Русская буква х заменяется только между числами. Глобальная замена испортила бы обычные слова вроде хомут и находится.
Алиасы товарных типов
Алиасы хранятся как каноническое понятие и массив вариантов:
PRODUCT_ALIASES = [
{
"canonical": "ушм",
"aliases": [
"ушм",
"болгарка",
"углошлифовальная машина",
"угловая шлифмашина",
"угловая шлифовальная машина",
],
},
]
При создании matcher строится обратный индекс:
alias_to_canonical[normalize_text(alias)] = canonical
Это словарь предметной области, а не ручное сопоставление пользовательских фраз с конкретными SKU. Алиас определяет только тип товара. Конкретная позиция всё равно проходит поиск и проверку характеристик.
Длинное точное выражение имеет приоритет над коротким. Поэтому:
круг зачистнойопределяется какдиск;- просто
кругопределяется как шлифовальный круг; дрель-шуруповертопределяется какшуруповерт, а не как обычная дрель;пластиковые хомутыопределяются как нейлоновые стяжки, а обычныйхомут— как червячный хомут.
Опечатки
Для опечаток применяется не одно сравнение всей строки, а несколько уровней:
- точный нормализованный alias;
- точное совпадение слов и многословных выражений;
- символьные триграммы для получения похожих терминов;
- unrestricted Damerau–Levenshtein для вставок, удалений, замен и перестановок соседних букв;
- LCS как слабый дополнительный сигнал для сильно повреждённых длинных слов.
Например:
шуруповерт
шурпуоверт
отличаются одной соседней перестановкой. А повреждённое шураыввавёрт всё ещё может восстановиться до категории шуруповерт, но низкая уверенность в категории не превращает один из нескольких шуруповёртов в ложный 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:
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.
Запросы без характеристик не выбрасываются
Если товарная категория понятна, сообщение считается товарным даже без размера или модели:
нужен кабель
дайте дюбелей
сверло нужно
какие есть диски
перфоратор посоветуйте
Такие запросы возвращают ambiguous и до трёх вариантов.
Логика статусов:
товарный тип не определён
→ not_found
товарный тип определён, совместимых SKU несколько
→ ambiguous
после жёстких проверок остался один SKU
→ matched
товарный тип понятен, но запрошенной комбинации характеристик нет
→ not_found
Нетоварные сообщения
Приветствия и слова подскажите, дайте, нужно заранее не удаляются и не считаются признаком мусора. Сначала всегда ищется товарный evidence.
Поэтому:
статус заказа 4512 и еще нужен бур 8х160
корректно находит бур.
Только если товарных признаков нет, дополнительный словарь фраз распознаёт обращения про режим работы, оплату, адрес и статус заказа. Незнакомый нетоварный текст и известный сервисный intent по контракту одинаково возвращают not_found.
Цена и рекомендации
Слова дешевле, недорогой, бюджетный не являются идентификатором SKU. Они влияют на сортировку совместимых кандидатов по цене, но результат остаётся ambiguous, если объективно подходят несколько товаров.
Например:
шуруповерт как у макиты, только дешевле
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 примерно с такими полями:
store_id
sku
name
normalized_name
product_type
brand
model_code
unit
price
parsed_attributes
catalog_version
Часто используемые характеристики лучше хранить отдельными типизированными колонками, а редкие — в JSONB:
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-пайплайн
разобрать запрос в приложении
↓
определить 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.
Тесты
Запуск:
python -m pytest -q
Набор тестов покрывает:
- точные совпадения;
- алиасы и разговорные названия;
- Damerau–Levenshtein и тяжёлые опечатки;
- вывод товарного типа по одному слову и шумным фразам;
- разные записи размеров;
- отсутствующие размеры, профили и резьбы;
- неоднозначные фасовки;
- широкие запросы без характеристик;
- нетоварные сообщения;
- приоритет товарных признаков над сервисными словами;
- исходный порядок сообщений;
- максимум три кандидата;
- диапазон confidence;
- строгий формат request/response;
- единственный endpoint
/match; - self-retrieval всех позиций каталога по полному названию;
- поиск по каждому каноническому типу, бренду и модельному коду;
- exact-only поиск без названия товара по размерам и структурным атрибутам.
tests/test_message_corpus.py автоматически читает все непустые строки из
messages.txt и more_messages.txt, прогоняет их через matcher и строго
сравнивает статус и SKU с таблицей EXPECTED_RESULTS. Ключом служит сам текст
сообщения, поэтому добавление строки не сдвигает остальные ожидания:
"бур sds 10х210": ("matched", ("BIT-0080",)),
"диск": ("ambiguous", ("DSK-0011", "DSK-0012", "DSK-0013")),
Для ambiguous SKU перечисляются в ожидаемом порядке. Confidence намеренно не
фиксируется точным числом: тест проверяет только допустимый диапазон, чтобы
изменение формулы оценки не ломало проверенные статус и состав кандидатов.
Структура
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:
origin https://git.mcbcorp.ru/Fiden/test-search-engine.git
Отправка выполняется на машине с настроенным SSH/HTTP-доступом к Gitea:
git push -u origin main