Rewrite README with design tradeoffs
This commit is contained in:
1 parent
db393941d8
commit
bffe179eba
1 file changed
+16
-245
@@ -1,262 +1,33 @@
|
|||||||
# Offline Product Matcher
|
# Поиск товара по сообщению
|
||||||
|
|
||||||
Небольшой FastAPI-сервис для поиска товаров из `catalog_excel.csv` по сообщениям
|
## 1. Запуск
|
||||||
покупателей. Работает локально, без внешних API и ML-моделей.
|
|
||||||
|
|
||||||
## Запуск
|
Нужен Python 3.12. Из корня проекта:
|
||||||
|
|
||||||
Нужен Python 3.12.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m pip install -r requirements.txt
|
python -m pip install -r requirements.txt && python -m app
|
||||||
python -m app
|
|
||||||
```
|
```
|
||||||
|
|
||||||
По умолчанию каталог читается из `catalog_excel.csv` в корне проекта. Другой
|
Сервис поднимется на `http://localhost:8000`, метод — `POST /match`.
|
||||||
путь можно передать через `CATALOG_PATH`:
|
|
||||||
|
|
||||||
```powershell
|
## 2. Решения и компромиссы
|
||||||
$env:CATALOG_PATH="C:\path\to\catalog.csv"
|
|
||||||
python -m app
|
|
||||||
```
|
|
||||||
|
|
||||||
Сервис запускается на `http://127.0.0.1:8000`.
|
Каталог небольшой — 466 позиций, поэтому он один раз загружается в память. Запрос нормализуется, из него извлекаются тип товара, бренд, модель и характеристики. Несовместимые позиции отсекаются точными правилами, оставшиеся ранжируются с помощью BM25, символьных триграмм и Damerau–Levenshtein.
|
||||||
|
|
||||||
## API
|
Чистое нечёткое ранжирование я отбросил: оно помогает с опечатками, но не должно решать, совместимы ли `125` и `150`, `T30` и `T50` или `3×2.5` и `4×2.5`. Поэтому текстовая похожесть отвечает за поиск кандидатов, а числа, модели и характеристики — за жёсткую фильтрацию. Elasticsearch позволил бы собрать похожую схему, но для 466 строк добавил бы отдельный сервис и лишнюю настройку без практической пользы.
|
||||||
|
|
||||||
В обычном режиме доступен один маршрут:
|
Алиасы хранятся как «канонический тип → несколько вариантов»: например, одно понятие может находиться по полному названию, сокращению или разговорному слову. Регулярные выражения используются для нормализации и разбора размеров, резьбы, мощности и других форматов; пустые сообщения отсекаются отдельно.
|
||||||
|
|
||||||
```text
|
Трансформеры ТЗ напрямую не запрещает — запрещены внешние API, интернет и ключи. Локальную модель я осознанно не добавлял: она утяжелила бы запуск, а точные артикулы и размеры всё равно пришлось бы проверять правилами. При сомнении сервис возвращает `ambiguous`, а не угадывает. `confidence` — оценка для сортировки, не вероятность.
|
||||||
POST /match
|
|
||||||
```
|
|
||||||
|
|
||||||
Пример запроса:
|
До продакшена я бы вынес алиасы и правила из кода, проверил качество на размеченных реальных запросах, откалибровал пороги, добавил метрики, журнал причин отказа и нагрузочные тесты. Базу, Docker, авторизацию и фронтенд не делал: для тестового задания они не нужны.
|
||||||
|
|
||||||
```bash
|
## 3. Масштаб
|
||||||
curl -X POST http://127.0.0.1:8000/match \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"messages":["Арс-12","удар","ушм на 150"]}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Ответ:
|
При 2 млн позиций и 50 магазинах каталог нельзя загружать и перебирать в каждом процессе. В PostgreSQL я бы хранил `store_id`, `sku`, нормализованное название, тип, бренд, модель и часто используемые характеристики в отдельных типизированных колонках; редкие атрибуты — в `JSONB`.
|
||||||
|
|
||||||
```json
|
Нужны как минимум уникальный B-tree по `(store_id, sku)` и составные B-tree-индексы, начинающиеся со `store_id`, для точных фильтров — например `(store_id, product_type, brand)`. Для размеров и других чисел индексы подбирались бы по реальным запросам. По названию B-tree не поможет: там нужны GIN-индексы для PostgreSQL FTS и/или `pg_trgm`.
|
||||||
{
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"message": "Арс-12",
|
|
||||||
"status": "matched",
|
|
||||||
"candidates": [
|
|
||||||
{"sku": "INS-0017", "confidence": 0.9108}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"message": "удар",
|
|
||||||
"status": "ambiguous",
|
|
||||||
"candidates": [
|
|
||||||
{"sku": "INS-0008", "confidence": 0.6884},
|
|
||||||
{"sku": "INS-0009", "confidence": 0.6764},
|
|
||||||
{"sku": "INS-0022", "confidence": 0.6635}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"message": "ушм на 150",
|
|
||||||
"status": "not_found",
|
|
||||||
"candidates": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Статусы:
|
Поиск останется двухэтапным: PostgreSQL по магазину, типу и точным характеристикам выберет небольшой набор кандидатов, приложение проверит совместимость и сформирует ответ. `store_id` должен приходить из контекста запроса и участвовать во всех запросах к БД; при необходимости таблицу можно секционировать по нему.
|
||||||
|
|
||||||
| Статус | Значение |
|
Векторный поиск через `pgvector` можно добавить для семантического расширения кандидатов, если замеры покажут пользу. Полностью заменять им точные фильтры нельзя: эмбеддинги ненадёжны для размеров, моделей и артикулов. Загрузку каталогов я сделал бы версионной: импорт во временную версию, проверка и атомарное переключение без простоя.
|
||||||
| --- | --- |
|
|
||||||
| `matched` | После фильтрации остался один товар |
|
|
||||||
| `ambiguous` | Подходит несколько товаров; возвращаются первые три |
|
|
||||||
| `not_found` | Тип товара не распознан или характеристики не совпали |
|
|
||||||
|
|
||||||
`confidence` нужен для сортировки результатов. Это не вероятность и не
|
|
||||||
калиброванная оценка качества.
|
|
||||||
|
|
||||||
## Поиск
|
|
||||||
|
|
||||||
Обработка запроса выглядит так:
|
|
||||||
|
|
||||||
```text
|
|
||||||
нормализация текста
|
|
||||||
↓
|
|
||||||
определение типа товара
|
|
||||||
↓
|
|
||||||
разбор бренда, модели и характеристик
|
|
||||||
↓
|
|
||||||
отбор совместимых позиций
|
|
||||||
↓
|
|
||||||
лексическая сортировка
|
|
||||||
↓
|
|
||||||
matched / ambiguous / not_found
|
|
||||||
```
|
|
||||||
|
|
||||||
### Нормализация
|
|
||||||
|
|
||||||
Текст приводится к нижнему регистру и Unicode NFKC, `ё` заменяется на `е`,
|
|
||||||
десятичная запятая — на точку. Записи размеров `3,5х25`, `3.5x25` и
|
|
||||||
`3.5×25` получают одинаковое представление.
|
|
||||||
|
|
||||||
### Тип товара
|
|
||||||
|
|
||||||
Сначала проверяются известные названия и разговорные варианты: `ушм`,
|
|
||||||
`болгарка`, `шурик`, `гкл`, `круг зачистной` и другие.
|
|
||||||
|
|
||||||
Опечатки обрабатываются символьными триграммами и
|
|
||||||
Damerau–Levenshtein. Если явного названия нет, тип можно определить по словам
|
|
||||||
в каталоге. Например, `удар` приводит к ударным дрелям. Для такого перехода
|
|
||||||
три лучших каталожных совпадения должны относиться к одному типу и иметь общее
|
|
||||||
совпавшее слово. Одного числа недостаточно: `аквариум 125 мм` не превращается
|
|
||||||
в запрос на УШМ.
|
|
||||||
|
|
||||||
### Характеристики
|
|
||||||
|
|
||||||
Из текста извлекаются:
|
|
||||||
|
|
||||||
- размеры и длины;
|
|
||||||
- резьба;
|
|
||||||
- профиль бит;
|
|
||||||
- зернистость;
|
|
||||||
- напряжение и мощность;
|
|
||||||
- количество зубьев;
|
|
||||||
- фасовка и единица продажи;
|
|
||||||
- бренд и модель;
|
|
||||||
- тип SDS;
|
|
||||||
- разновидность товара: по дереву, по металлу, ГКЛ, отрезной, пильный и т. п.
|
|
||||||
|
|
||||||
Явное несовпадение исключает позицию. `T50` не совпадает с `T30`,
|
|
||||||
`150 мм` — со `125 мм`, а `4х2.5` — с `3х2.5`.
|
|
||||||
|
|
||||||
Неполные характеристики допустимы. Для `труба 20х20` подойдут разные толщины
|
|
||||||
стенки, поэтому ответ будет `ambiguous`.
|
|
||||||
|
|
||||||
В модельном коде можно пропустить конечную букву: `АРС-12` совпадает с
|
|
||||||
`АРС-12Л`. Числовая часть сравнивается целиком, поэтому `АРС-12` не совпадёт
|
|
||||||
с `АРС-125`.
|
|
||||||
|
|
||||||
Бренды `Makita` и `Макита` нормализуются в один бренд. В запросе
|
|
||||||
`как у макиты, только дешевле` бренд считается ориентиром, а не обязательным
|
|
||||||
фильтром. Слово `дешевле` включает сортировку по возрастанию цены.
|
|
||||||
|
|
||||||
### Примеры
|
|
||||||
|
|
||||||
| Запрос | Результат |
|
|
||||||
| --- | --- |
|
|
||||||
| `Арс-12` | `INS-0017` |
|
|
||||||
| `210 мм 24 зуба` | `DSK-0035` |
|
|
||||||
| `24 зуба` | три пильных диска с 24 зубьями |
|
|
||||||
| `ToolKraft 125 мм` | `INS-0027` |
|
|
||||||
| `удар мощно 900 хромированый анус долбить` | ударные дрели на 900 Вт |
|
|
||||||
| `бита T50` | `not_found`, если T50 нет в каталоге |
|
|
||||||
| `аквариум 125 мм` | `not_found` |
|
|
||||||
|
|
||||||
## Локальная страница для проверки
|
|
||||||
|
|
||||||
`search.html` отправляет запросы в `/match` и подгружает названия и цены
|
|
||||||
товаров. Файл исключён из Git и нужен только для ручной проверки.
|
|
||||||
|
|
||||||
Чтобы страница могла получать данные по SKU, запустите приложение с тестовым
|
|
||||||
маршрутом:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:ENABLE_TEST_INFO_ROUTE="1"
|
|
||||||
python -m app
|
|
||||||
```
|
|
||||||
|
|
||||||
После этого откройте `search.html` в браузере. Дополнительный маршрут имеет вид
|
|
||||||
`GET /get_info_by/{sku}`. Без `ENABLE_TEST_INFO_ROUTE` он не регистрируется,
|
|
||||||
и приложение предоставляет только `POST /match`.
|
|
||||||
|
|
||||||
## Тесты
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest -q
|
|
||||||
```
|
|
||||||
|
|
||||||
`tests/test_message_corpus.py` читает каждую непустую строку из
|
|
||||||
`messages.txt` и `more_messages.txt`. Для каждого запроса явно указан
|
|
||||||
ожидаемый статус и порядок SKU. Добавленная в файл строка без ожидаемого
|
|
||||||
результата ломает тест.
|
|
||||||
|
|
||||||
Дополнительно проверяются:
|
|
||||||
|
|
||||||
- полные названия всех товаров каталога;
|
|
||||||
- канонические типы, бренды и модельные коды;
|
|
||||||
- размеры, резьба, профили, зернистость, мощность и другие атрибуты;
|
|
||||||
- опечатки и короткие запросы;
|
|
||||||
- ложные совпадения на числах и мусорном тексте;
|
|
||||||
- сортировка по цене;
|
|
||||||
- формат API и количество маршрутов.
|
|
||||||
|
|
||||||
## Структура проекта
|
|
||||||
|
|
||||||
```text
|
|
||||||
app/
|
|
||||||
├── aliases.py # типы товаров и их варианты
|
|
||||||
├── attributes.py # разбор и сравнение характеристик
|
|
||||||
├── catalog.py # загрузка каталога
|
|
||||||
├── dev_routes.py # тестовый маршрут с данными по SKU
|
|
||||||
├── intents.py # нетоварные обращения
|
|
||||||
├── main.py # FastAPI-приложение
|
|
||||||
├── matcher.py # основной поиск
|
|
||||||
├── models.py # модели API
|
|
||||||
├── normalization.py
|
|
||||||
├── search_index.py # BM25 и триграммы
|
|
||||||
└── typo.py # расстояния и похожесть строк
|
|
||||||
|
|
||||||
tests/
|
|
||||||
├── test_api.py
|
|
||||||
├── test_catalog_self_retrieval.py
|
|
||||||
├── test_matcher.py
|
|
||||||
├── test_message_corpus.py
|
|
||||||
└── test_normalization.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Если каталог станет большим
|
|
||||||
|
|
||||||
Текущая версия загружает каталог в память и рассчитана на несколько сотен
|
|
||||||
позиций. Для миллионов SKU поиск нужно перенести в базу:
|
|
||||||
|
|
||||||
- PostgreSQL с отдельными колонками для часто используемых характеристик;
|
|
||||||
- `JSONB` для редких атрибутов;
|
|
||||||
- B-tree индексы по магазину, типу товара, бренду и модели;
|
|
||||||
- `pg_trgm` или FTS для получения короткого списка кандидатов;
|
|
||||||
- проверка характеристик и финальная сортировка в приложении.
|
|
||||||
|
|
||||||
Пример последовательности:
|
|
||||||
|
|
||||||
```text
|
|
||||||
разобрать запрос
|
|
||||||
↓
|
|
||||||
определить магазин и тип товара
|
|
||||||
↓
|
|
||||||
применить точные фильтры в PostgreSQL
|
|
||||||
↓
|
|
||||||
получить 20–100 кандидатов через pg_trgm/FTS
|
|
||||||
↓
|
|
||||||
проверить совместимость и отсортировать
|
|
||||||
```
|
|
||||||
|
|
||||||
Для нескольких магазинов нужен `store_id`. Формат тела `/match` менять
|
|
||||||
нельзя, поэтому идентификатор можно брать из авторизации, hostname или HTTP
|
|
||||||
заголовка. Импорт каталога должен быть версионным: загрузка во временную
|
|
||||||
таблицу, проверка и атомарное переключение активной версии.
|
|
||||||
|
|
||||||
Elasticsearch имеет смысл добавлять только после замеров, если PostgreSQL не
|
|
||||||
справляется с полнотекстовым поиском или нагрузкой.
|
|
||||||
|
|
||||||
## Ограничения
|
|
||||||
|
|
||||||
- правила разбора привязаны к текущим товарным группам;
|
|
||||||
- `confidence` не калиброван на реальных обращениях;
|
|
||||||
- неизвестные цвета, диапазоны и сложные сравнительные запросы могут
|
|
||||||
потребовать новых правил;
|
|
||||||
- сервисные обращения и неизвестный текст возвращают одинаковый
|
|
||||||
`not_found`;
|
|
||||||
- остатки товаров в каталоге отсутствуют, поэтому наличие не проверяется.
|
|
||||||
Reference in new issue
Block a user