Explain design choices and production scaling
This commit is contained in:
@@ -0,0 +1,491 @@
|
||||
# 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`.
|
||||
|
||||
### Лексическое ранжирование
|
||||
|
||||
После определения товарного типа поиск ограничивается соответствующей частью каталога. Внутри неё кандидаты получают совокупный лексический 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.
|
||||
|
||||
### Запросы без характеристик не выбрасываются
|
||||
|
||||
Если товарная категория понятна, сообщение считается товарным даже без размера или модели:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
В текущем наборе 67 тестов:
|
||||
|
||||
- точные совпадения;
|
||||
- алиасы и разговорные названия;
|
||||
- Damerau–Levenshtein и тяжёлые опечатки;
|
||||
- разные записи размеров;
|
||||
- отсутствующие размеры, профили и резьбы;
|
||||
- неоднозначные фасовки;
|
||||
- широкие запросы без характеристик;
|
||||
- нетоварные сообщения;
|
||||
- приоритет товарных признаков над сервисными словами;
|
||||
- исходный порядок сообщений;
|
||||
- максимум три кандидата;
|
||||
- диапазон confidence;
|
||||
- строгий формат request/response;
|
||||
- единственный endpoint `/match`.
|
||||
|
||||
## Структура
|
||||
|
||||
```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_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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user