Explain design choices and production scaling

This commit is contained in:
Fiden
2026-08-07 13:17:25 +00:00
parent 11e1a4c4a2
commit 476cbb0116
+491
View File
@@ -0,0 +1,491 @@
# Offline Product Matcher
Детерминированный HTTP-сервис для сопоставления живых сообщений покупателей с товарами из `catalog_excel.csv`.
Внутри сервиса **нет LLM, BERT, embedding-моделей, внешних API, сетевых запросов и ключей**. Поиск построен на нормализации текста, доменных алиасах, BM25, символьных n-граммах, DamerauLevenshtein и строгой проверке характеристик товара.
## 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 DamerauLevenshtein для вставок, удалений, замен и перестановок соседних букв;
5. LCS как слабый дополнительный сигнал для сильно повреждённых длинных слов.
Например:
```text
шуруповерт
шурпуоверт
```
отличаются одной соседней перестановкой. А повреждённое `шураыввавёрт` всё ещё может восстановиться до категории `шуруповерт`, но низкая уверенность в категории не превращает один из нескольких шуруповёртов в ложный `matched`.
### Лексическое ранжирование
После определения товарного типа поиск ограничивается соответствующей частью каталога. Внутри неё кандидаты получают совокупный лексический score:
- BM25 по токенам;
- Dice similarity по символьным триграммам;
- выравнивание слов с DamerauLevenshtein;
- покрытие точных токенов запроса.
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 тестов:
- точные совпадения;
- алиасы и разговорные названия;
- DamerauLevenshtein и тяжёлые опечатки;
- разные записи размеров;
- отсутствующие размеры, профили и резьбы;
- неоднозначные фасовки;
- широкие запросы без характеристик;
- нетоварные сообщения;
- приоритет товарных признаков над сервисными словами;
- исходный порядок сообщений;
- максимум три кандидата;
- диапазон 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
```