Files
test-search-engine/README.md
T

525 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.
### Лексический 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-тип;
- различающие варианты товара: по дереву, по металлу, ГКЛ, отрезной, зачистной, пильный и так далее.
Явные характеристики являются 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.
Для модельного кода можно опустить только конечную букву: `АРС-12` совпадает с
`АРС-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
python -m pytest -q
```
Набор тестов покрывает:
- точные совпадения;
- алиасы и разговорные названия;
- 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`. Ключом служит сам текст
сообщения, поэтому добавление строки не сдвигает остальные ожидания:
```python
"бур sds 10х210": ("matched", ("BIT-0080",)),
"диск": ("ambiguous", ("DSK-0011", "DSK-0012", "DSK-0013")),
```
Для `ambiguous` SKU перечисляются в ожидаемом порядке. Confidence намеренно не
фиксируется точным числом: тест проверяет только допустимый диапазон, чтобы
изменение формулы оценки не ломало проверенные статус и состав кандидатов.
## Структура
```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_message_corpus.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
```