Tighten API contract and simplify documentation
This commit is contained in:
1 parent
6a2d912ca5
commit
db393941d8
3 files changed
+188
-425
No files matched your search
@@ -1,26 +1,30 @@
|
|||||||
# Offline Product Matcher
|
# Offline Product Matcher
|
||||||
|
|
||||||
Детерминированный HTTP-сервис для сопоставления живых сообщений покупателей с товарами из `catalog_excel.csv`.
|
Небольшой FastAPI-сервис для поиска товаров из `catalog_excel.csv` по сообщениям
|
||||||
|
покупателей. Работает локально, без внешних API и ML-моделей.
|
||||||
|
|
||||||
Внутри сервиса **нет LLM, BERT, embedding-моделей, внешних API, сетевых запросов и ключей**. Поиск построен на нормализации текста, доменных алиасах, BM25, символьных n-граммах, Damerau–Levenshtein и строгой проверке характеристик товара.
|
## Запуск
|
||||||
|
|
||||||
## 1. Как запустить
|
Нужен Python 3.12.
|
||||||
|
|
||||||
Требуется Python 3.12.
|
|
||||||
|
|
||||||
Один раз установить зависимости:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m pip install -r requirements.txt
|
python -m pip install -r requirements.txt
|
||||||
```
|
|
||||||
|
|
||||||
После этого сервис поднимается одной командой:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m app
|
python -m app
|
||||||
```
|
```
|
||||||
|
|
||||||
Сервис слушает `0.0.0.0:8000` и предоставляет только один endpoint:
|
По умолчанию каталог читается из `catalog_excel.csv` в корне проекта. Другой
|
||||||
|
путь можно передать через `CATALOG_PATH`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:CATALOG_PATH="C:\path\to\catalog.csv"
|
||||||
|
python -m app
|
||||||
|
```
|
||||||
|
|
||||||
|
Сервис запускается на `http://127.0.0.1:8000`.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
В обычном режиме доступен один маршрут:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
POST /match
|
POST /match
|
||||||
@@ -31,46 +35,28 @@ POST /match
|
|||||||
```bash
|
```bash
|
||||||
curl -X POST http://127.0.0.1:8000/match \
|
curl -X POST http://127.0.0.1:8000/match \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{"messages":["Арс-12","удар","ушм на 150"]}'
|
||||||
"messages": [
|
|
||||||
"дрель ударная prowerk pw-750 в наличии?",
|
|
||||||
"нужен кабель",
|
|
||||||
"ушм на 150"
|
|
||||||
]
|
|
||||||
}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Пример ответа:
|
Ответ:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"message": "дрель ударная prowerk pw-750 в наличии?",
|
"message": "Арс-12",
|
||||||
"status": "matched",
|
"status": "matched",
|
||||||
"candidates": [
|
"candidates": [
|
||||||
{
|
{"sku": "INS-0017", "confidence": 0.9108}
|
||||||
"sku": "INS-0008",
|
|
||||||
"confidence": 0.9489
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"message": "нужен кабель",
|
"message": "удар",
|
||||||
"status": "ambiguous",
|
"status": "ambiguous",
|
||||||
"candidates": [
|
"candidates": [
|
||||||
{
|
{"sku": "INS-0008", "confidence": 0.6884},
|
||||||
"sku": "KAB-0004",
|
{"sku": "INS-0009", "confidence": 0.6764},
|
||||||
"confidence": 0.7843
|
{"sku": "INS-0022", "confidence": 0.6635}
|
||||||
},
|
|
||||||
{
|
|
||||||
"sku": "KAB-0005",
|
|
||||||
"confidence": 0.7723
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sku": "KAB-0008",
|
|
||||||
"confidence": 0.7603
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -82,443 +68,195 @@ curl -X POST http://127.0.0.1:8000/match \
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Путь к каталогу можно переопределить переменной окружения:
|
Статусы:
|
||||||
|
|
||||||
```bash
|
| Статус | Значение |
|
||||||
CATALOG_PATH=/path/to/catalog.csv python -m app
|
| --- | --- |
|
||||||
```
|
| `matched` | После фильтрации остался один товар |
|
||||||
|
| `ambiguous` | Подходит несколько товаров; возвращаются первые три |
|
||||||
|
| `not_found` | Тип товара не распознан или характеристики не совпали |
|
||||||
|
|
||||||
На Windows PowerShell:
|
`confidence` нужен для сортировки результатов. Это не вероятность и не
|
||||||
|
калиброванная оценка качества.
|
||||||
|
|
||||||
```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
|
```text
|
||||||
исходное сообщение
|
нормализация текста
|
||||||
↓
|
↓
|
||||||
Unicode- и текстовая нормализация
|
определение типа товара
|
||||||
↓
|
↓
|
||||||
поиск товарного типа и доменных алиасов
|
разбор бренда, модели и характеристик
|
||||||
↓
|
↓
|
||||||
восстановление опечаток
|
отбор совместимых позиций
|
||||||
↓
|
↓
|
||||||
вывод типа по триграммам, если алиаса нет
|
лексическая сортировка
|
||||||
↓
|
|
||||||
лексическое ранжирование кандидатов
|
|
||||||
↓
|
|
||||||
извлечение типизированных характеристик
|
|
||||||
↓
|
|
||||||
жёсткое удаление несовместимых SKU
|
|
||||||
↓
|
↓
|
||||||
matched / ambiguous / not_found
|
matched / ambiguous / not_found
|
||||||
```
|
```
|
||||||
|
|
||||||
### Нормализация
|
### Нормализация
|
||||||
|
|
||||||
До поиска выполняются:
|
Текст приводится к нижнему регистру и Unicode NFKC, `ё` заменяется на `е`,
|
||||||
|
десятичная запятая — на точку. Записи размеров `3,5х25`, `3.5x25` и
|
||||||
|
`3.5×25` получают одинаковое представление.
|
||||||
|
|
||||||
- Unicode NFKC;
|
### Тип товара
|
||||||
- приведение к нижнему регистру;
|
|
||||||
- `ё → е`;
|
|
||||||
- `3,5 → 3.5`;
|
|
||||||
- `×`, `х`, `x`, `*` между числами приводятся к `x`;
|
|
||||||
- нормализация тире, пунктуации и пробелов;
|
|
||||||
- канонизация технических обозначений `М10 → m10`, `Р120 → p120`.
|
|
||||||
|
|
||||||
Русская буква `х` заменяется только между числами. Глобальная замена испортила бы обычные слова вроде `хомут` и `находится`.
|
Сначала проверяются известные названия и разговорные варианты: `ушм`,
|
||||||
|
`болгарка`, `шурик`, `гкл`, `круг зачистной` и другие.
|
||||||
|
|
||||||
### Алиасы товарных типов
|
Опечатки обрабатываются символьными триграммами и
|
||||||
|
Damerau–Levenshtein. Если явного названия нет, тип можно определить по словам
|
||||||
|
в каталоге. Например, `удар` приводит к ударным дрелям. Для такого перехода
|
||||||
|
три лучших каталожных совпадения должны относиться к одному типу и иметь общее
|
||||||
|
совпавшее слово. Одного числа недостаточно: `аквариум 125 мм` не превращается
|
||||||
|
в запрос на УШМ.
|
||||||
|
|
||||||
Алиасы хранятся как каноническое понятие и массив вариантов:
|
### Характеристики
|
||||||
|
|
||||||
```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-тип;
|
- тип SDS;
|
||||||
- различающие варианты товара: по дереву, по металлу, ГКЛ, отрезной, зачистной, пильный и так далее.
|
- разновидность товара: по дереву, по металлу, ГКЛ, отрезной, пильный и т. п.
|
||||||
|
|
||||||
Явные характеристики являются hard constraints:
|
Явное несовпадение исключает позицию. `T50` не совпадает с `T30`,
|
||||||
|
`150 мм` — со `125 мм`, а `4х2.5` — с `3х2.5`.
|
||||||
|
|
||||||
```text
|
Неполные характеристики допустимы. Для `труба 20х20` подойдут разные толщины
|
||||||
T50 != T30
|
стенки, поэтому ответ будет `ambiguous`.
|
||||||
M16 != M12
|
|
||||||
4х2.5 != 3х2.5
|
В модельном коде можно пропустить конечную букву: `АРС-12` совпадает с
|
||||||
150 мм != 125 мм
|
`АРС-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
|
||||||
```
|
```
|
||||||
|
|
||||||
Частичная спецификация допускается. Запрос `труба 20х20` совместим и с `20х20х1.5`, и с `20х20х2`, поэтому результат будет `ambiguous`. Запрос `труба 20х20 стенка полтора` оставляет один SKU.
|
После этого откройте `search.html` в браузере. Дополнительный маршрут имеет вид
|
||||||
|
`GET /get_info_by/{sku}`. Без `ENABLE_TEST_INFO_ROUTE` он не регистрируется,
|
||||||
Для модельного кода можно опустить только конечную букву: `АРС-12` совпадает с
|
и приложение предоставляет только `POST /match`.
|
||||||
`АРС-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
|
```bash
|
||||||
python -m pytest -q
|
python -m pytest -q
|
||||||
```
|
```
|
||||||
|
|
||||||
Набор тестов покрывает:
|
`tests/test_message_corpus.py` читает каждую непустую строку из
|
||||||
|
`messages.txt` и `more_messages.txt`. Для каждого запроса явно указан
|
||||||
|
ожидаемый статус и порядок SKU. Добавленная в файл строка без ожидаемого
|
||||||
|
результата ломает тест.
|
||||||
|
|
||||||
- точные совпадения;
|
Дополнительно проверяются:
|
||||||
- алиасы и разговорные названия;
|
|
||||||
- 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`. Ключом служит сам текст
|
- размеры, резьба, профили, зернистость, мощность и другие атрибуты;
|
||||||
сообщения, поэтому добавление строки не сдвигает остальные ожидания:
|
- опечатки и короткие запросы;
|
||||||
|
- ложные совпадения на числах и мусорном тексте;
|
||||||
|
- сортировка по цене;
|
||||||
|
- формат API и количество маршрутов.
|
||||||
|
|
||||||
```python
|
## Структура проекта
|
||||||
"бур sds 10х210": ("matched", ("BIT-0080",)),
|
|
||||||
"диск": ("ambiguous", ("DSK-0011", "DSK-0012", "DSK-0013")),
|
|
||||||
```
|
|
||||||
|
|
||||||
Для `ambiguous` SKU перечисляются в ожидаемом порядке. Confidence намеренно не
|
|
||||||
фиксируется точным числом: тест проверяет только допустимый диапазон, чтобы
|
|
||||||
изменение формулы оценки не ломало проверенные статус и состав кандидатов.
|
|
||||||
|
|
||||||
## Структура
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
app/
|
app/
|
||||||
├── __main__.py
|
├── aliases.py # типы товаров и их варианты
|
||||||
├── aliases.py
|
├── attributes.py # разбор и сравнение характеристик
|
||||||
├── attributes.py
|
├── catalog.py # загрузка каталога
|
||||||
├── catalog.py
|
├── dev_routes.py # тестовый маршрут с данными по SKU
|
||||||
├── intents.py
|
├── intents.py # нетоварные обращения
|
||||||
├── main.py
|
├── main.py # FastAPI-приложение
|
||||||
├── matcher.py
|
├── matcher.py # основной поиск
|
||||||
├── models.py
|
├── models.py # модели API
|
||||||
├── normalization.py
|
├── normalization.py
|
||||||
├── search_index.py
|
├── search_index.py # BM25 и триграммы
|
||||||
└── typo.py
|
└── typo.py # расстояния и похожесть строк
|
||||||
|
|
||||||
tests/
|
tests/
|
||||||
├── conftest.py
|
|
||||||
├── test_api.py
|
├── test_api.py
|
||||||
├── test_message_corpus.py
|
├── test_catalog_self_retrieval.py
|
||||||
├── test_matcher.py
|
├── test_matcher.py
|
||||||
|
├── test_message_corpus.py
|
||||||
└── test_normalization.py
|
└── test_normalization.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## Git
|
## Если каталог станет большим
|
||||||
|
|
||||||
В архив включён локальный Git-репозиторий с веткой `main`, осмысленной историей без Conventional Commits-префиксов и настроенным remote:
|
Текущая версия загружает каталог в память и рассчитана на несколько сотен
|
||||||
|
позиций. Для миллионов SKU поиск нужно перенести в базу:
|
||||||
|
|
||||||
|
- PostgreSQL с отдельными колонками для часто используемых характеристик;
|
||||||
|
- `JSONB` для редких атрибутов;
|
||||||
|
- B-tree индексы по магазину, типу товара, бренду и модели;
|
||||||
|
- `pg_trgm` или FTS для получения короткого списка кандидатов;
|
||||||
|
- проверка характеристик и финальная сортировка в приложении.
|
||||||
|
|
||||||
|
Пример последовательности:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
origin https://git.mcbcorp.ru/Fiden/test-search-engine.git
|
разобрать запрос
|
||||||
|
↓
|
||||||
|
определить магазин и тип товара
|
||||||
|
↓
|
||||||
|
применить точные фильтры в PostgreSQL
|
||||||
|
↓
|
||||||
|
получить 20–100 кандидатов через pg_trgm/FTS
|
||||||
|
↓
|
||||||
|
проверить совместимость и отсортировать
|
||||||
```
|
```
|
||||||
|
|
||||||
Отправка выполняется на машине с настроенным SSH/HTTP-доступом к Gitea:
|
Для нескольких магазинов нужен `store_id`. Формат тела `/match` менять
|
||||||
|
нельзя, поэтому идентификатор можно брать из авторизации, hostname или HTTP
|
||||||
|
заголовка. Импорт каталога должен быть версионным: загрузка во временную
|
||||||
|
таблицу, проверка и атомарное переключение активной версии.
|
||||||
|
|
||||||
```bash
|
Elasticsearch имеет смысл добавлять только после замеров, если PostgreSQL не
|
||||||
git push -u origin main
|
справляется с полнотекстовым поиском или нагрузкой.
|
||||||
```
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- правила разбора привязаны к текущим товарным группам;
|
||||||
|
- `confidence` не калиброван на реальных обращениях;
|
||||||
|
- неизвестные цвета, диапазоны и сложные сравнительные запросы могут
|
||||||
|
потребовать новых правил;
|
||||||
|
- сервисные обращения и неизвестный текст возвращают одинаковый
|
||||||
|
`not_found`;
|
||||||
|
- остатки товаров в каталоге отсутствуют, поэтому наличие не проверяется.
|
||||||
+1
-1
@@ -8,7 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
class MatchRequest(BaseModel):
|
class MatchRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
messages: list[str] = Field(default_factory=list)
|
messages: list[str]
|
||||||
|
|
||||||
|
|
||||||
class Candidate(BaseModel):
|
class Candidate(BaseModel):
|
||||||
|
|||||||
@@ -38,12 +38,37 @@ def test_match_contract_and_message_order() -> None:
|
|||||||
assert 0.0 <= candidate["confidence"] <= 1.0
|
assert 0.0 <= candidate["confidence"] <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_example_uses_required_request_and_response_shape() -> None:
|
||||||
|
message = "саморезы по дереву 4.2 на 75, пачку"
|
||||||
|
response = client.post("/match", json={"messages": [message]})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert set(body) == {"results"}
|
||||||
|
assert len(body["results"]) == 1
|
||||||
|
|
||||||
|
result = body["results"][0]
|
||||||
|
assert set(result) == {"message", "status", "candidates"}
|
||||||
|
assert result["message"] == message
|
||||||
|
assert result["status"] in {"matched", "ambiguous", "not_found"}
|
||||||
|
for candidate in result["candidates"]:
|
||||||
|
assert set(candidate) == {"sku", "confidence"}
|
||||||
|
assert isinstance(candidate["sku"], str)
|
||||||
|
assert isinstance(candidate["confidence"], float)
|
||||||
|
|
||||||
|
|
||||||
def test_empty_message_list_is_valid() -> None:
|
def test_empty_message_list_is_valid() -> None:
|
||||||
response = client.post("/match", json={"messages": []})
|
response = client.post("/match", json={"messages": []})
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {"results": []}
|
assert response.json() == {"results": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_messages_field_is_required() -> None:
|
||||||
|
response = client.post("/match", json={})
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_request_fields_are_rejected() -> None:
|
def test_unknown_request_fields_are_rejected() -> None:
|
||||||
response = client.post("/match", json={"messages": [], "debug": True})
|
response = client.post("/match", json={"messages": [], "debug": True})
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|||||||
Reference in new issue
Block a user