Rewrite README with design tradeoffs

This commit is contained in:
Fiden committed 2026-08-07 23:07:35 +03:00
1 parent db393941d8
commit bffe179eba
1 file changed
+16 -245
+16 -245
View File
@@ -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` можно добавить для семантического расширения кандидатов, если замеры покажут пользу. Полностью заменять им точные фильтры нельзя: эмбеддинги ненадёжны для размеров, моделей и артикулов. Загрузку каталогов я сделал бы версионной: импорт во временную версию, проверка и атомарное переключение без простоя.