525 lines
24 KiB
Markdown
525 lines
24 KiB
Markdown
# 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
|
||
```
|