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` по сообщениям
|
||||
покупателей. Работает локально, без внешних API и ML-моделей.
|
||||
## 1. Запуск
|
||||
|
||||
## Запуск
|
||||
|
||||
Нужен Python 3.12.
|
||||
Нужен Python 3.12. Из корня проекта:
|
||||
|
||||
```bash
|
||||
python -m pip install -r requirements.txt
|
||||
python -m app
|
||||
python -m pip install -r requirements.txt && python -m app
|
||||
```
|
||||
|
||||
По умолчанию каталог читается из `catalog_excel.csv` в корне проекта. Другой
|
||||
путь можно передать через `CATALOG_PATH`:
|
||||
Сервис поднимется на `http://localhost:8000`, метод — `POST /match`.
|
||||
|
||||
```powershell
|
||||
$env:CATALOG_PATH="C:\path\to\catalog.csv"
|
||||
python -m app
|
||||
```
|
||||
## 2. Решения и компромиссы
|
||||
|
||||
Сервис запускается на `http://127.0.0.1:8000`.
|
||||
Каталог небольшой — 466 позиций, поэтому он один раз загружается в память. Запрос нормализуется, из него извлекаются тип товара, бренд, модель и характеристики. Несовместимые позиции отсекаются точными правилами, оставшиеся ранжируются с помощью BM25, символьных триграмм и Damerau–Levenshtein.
|
||||
|
||||
## API
|
||||
Чистое нечёткое ранжирование я отбросил: оно помогает с опечатками, но не должно решать, совместимы ли `125` и `150`, `T30` и `T50` или `3×2.5` и `4×2.5`. Поэтому текстовая похожесть отвечает за поиск кандидатов, а числа, модели и характеристики — за жёсткую фильтрацию. Elasticsearch позволил бы собрать похожую схему, но для 466 строк добавил бы отдельный сервис и лишнюю настройку без практической пользы.
|
||||
|
||||
В обычном режиме доступен один маршрут:
|
||||
Алиасы хранятся как «канонический тип → несколько вариантов»: например, одно понятие может находиться по полному названию, сокращению или разговорному слову. Регулярные выражения используются для нормализации и разбора размеров, резьбы, мощности и других форматов; пустые сообщения отсекаются отдельно.
|
||||
|
||||
```text
|
||||
POST /match
|
||||
```
|
||||
Трансформеры ТЗ напрямую не запрещает — запрещены внешние API, интернет и ключи. Локальную модель я осознанно не добавлял: она утяжелила бы запуск, а точные артикулы и размеры всё равно пришлось бы проверять правилами. При сомнении сервис возвращает `ambiguous`, а не угадывает. `confidence` — оценка для сортировки, не вероятность.
|
||||
|
||||
Пример запроса:
|
||||
До продакшена я бы вынес алиасы и правила из кода, проверил качество на размеченных реальных запросах, откалибровал пороги, добавил метрики, журнал причин отказа и нагрузочные тесты. Базу, Docker, авторизацию и фронтенд не делал: для тестового задания они не нужны.
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/match \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"messages":["Арс-12","удар","ушм на 150"]}'
|
||||
```
|
||||
## 3. Масштаб
|
||||
|
||||
Ответ:
|
||||
При 2 млн позиций и 50 магазинах каталог нельзя загружать и перебирать в каждом процессе. В PostgreSQL я бы хранил `store_id`, `sku`, нормализованное название, тип, бренд, модель и часто используемые характеристики в отдельных типизированных колонках; редкие атрибуты — в `JSONB`.
|
||||
|
||||
```json
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
Нужны как минимум уникальный B-tree по `(store_id, sku)` и составные B-tree-индексы, начинающиеся со `store_id`, для точных фильтров — например `(store_id, product_type, brand)`. Для размеров и других чисел индексы подбирались бы по реальным запросам. По названию B-tree не поможет: там нужны GIN-индексы для PostgreSQL FTS и/или `pg_trgm`.
|
||||
|
||||
Статусы:
|
||||
Поиск останется двухэтапным: PostgreSQL по магазину, типу и точным характеристикам выберет небольшой набор кандидатов, приложение проверит совместимость и сформирует ответ. `store_id` должен приходить из контекста запроса и участвовать во всех запросах к БД; при необходимости таблицу можно секционировать по нему.
|
||||
|
||||
| Статус | Значение |
|
||||
| --- | --- |
|
||||
| `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`;
|
||||
- остатки товаров в каталоге отсутствуют, поэтому наличие не проверяется.
|
||||
Векторный поиск через `pgvector` можно добавить для семантического расширения кандидатов, если замеры покажут пользу. Полностью заменять им точные фильтры нельзя: эмбеддинги ненадёжны для размеров, моделей и артикулов. Загрузку каталогов я сделал бы версионной: импорт во временную версию, проверка и атомарное переключение без простоя.
|
||||
Reference in new issue
Block a user