Offline Product Matcher

Детерминированный HTTP-сервис для сопоставления живых сообщений покупателей с товарами из catalog_excel.csv.

Внутри сервиса нет LLM, BERT, embedding-моделей, внешних API, сетевых запросов и ключей. Поиск построен на нормализации текста, доменных алиасах, BM25, символьных n-граммах, DamerauLevenshtein и строгой проверке характеристик товара.

1. Как запустить

Требуется Python 3.12.

Один раз установить зависимости:

python -m pip install -r requirements.txt

После этого сервис поднимается одной командой:

python -m app

Сервис слушает 0.0.0.0:8000 и предоставляет только один endpoint:

POST /match

Пример запроса:

curl -X POST http://127.0.0.1:8000/match \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [
      "дрель ударная prowerk pw-750 в наличии?",
      "нужен кабель",
      "ушм на 150"
    ]
  }'

Пример ответа:

{
  "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": []
    }
  ]
}

Путь к каталогу можно переопределить переменной окружения:

CATALOG_PATH=/path/to/catalog.csv python -m app

На Windows 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, даже если названия текстово очень похожи.

Пайплайн поиска

исходное сообщение
        ↓
Unicode- и текстовая нормализация
        ↓
поиск товарного типа и доменных алиасов
        ↓
восстановление опечаток
        ↓
лексическое ранжирование кандидатов
        ↓
извлечение типизированных характеристик
        ↓
жёсткое удаление несовместимых SKU
        ↓
matched / ambiguous / not_found

Нормализация

До поиска выполняются:

  • Unicode NFKC;
  • приведение к нижнему регистру;
  • ё → е;
  • 3,5 → 3.5;
  • ×, х, x, * между числами приводятся к x;
  • нормализация тире, пунктуации и пробелов;
  • канонизация технических обозначений М10 → m10, Р120 → p120.

Русская буква х заменяется только между числами. Глобальная замена испортила бы обычные слова вроде хомут и находится.

Алиасы товарных типов

Алиасы хранятся как каноническое понятие и массив вариантов:

PRODUCT_ALIASES = [
    {
        "canonical": "ушм",
        "aliases": [
            "ушм",
            "болгарка",
            "углошлифовальная машина",
            "угловая шлифмашина",
            "угловая шлифовальная машина",
        ],
    },
]

При создании matcher строится обратный индекс:

alias_to_canonical[normalize_text(alias)] = canonical

Это словарь предметной области, а не ручное сопоставление пользовательских фраз с конкретными SKU. Алиас определяет только тип товара. Конкретная позиция всё равно проходит поиск и проверку характеристик.

Длинное точное выражение имеет приоритет над коротким. Поэтому:

  • круг зачистной определяется как диск;
  • просто круг определяется как шлифовальный круг;
  • дрель-шуруповерт определяется как шуруповерт, а не как обычная дрель;
  • пластиковые хомуты определяются как нейлоновые стяжки, а обычный хомут — как червячный хомут.

Опечатки

Для опечаток применяется не одно сравнение всей строки, а несколько уровней:

  1. точный нормализованный alias;
  2. точное совпадение слов и многословных выражений;
  3. символьные триграммы для получения похожих терминов;
  4. unrestricted DamerauLevenshtein для вставок, удалений, замен и перестановок соседних букв;
  5. LCS как слабый дополнительный сигнал для сильно повреждённых длинных слов.

Например:

шуруповерт
шурпуоверт

отличаются одной соседней перестановкой. А повреждённое шураыввавёрт всё ещё может восстановиться до категории шуруповерт, но низкая уверенность в категории не превращает один из нескольких шуруповёртов в ложный 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:

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.

Запросы без характеристик не выбрасываются

Если товарная категория понятна, сообщение считается товарным даже без размера или модели:

нужен кабель
дайте дюбелей
сверло нужно
какие есть диски
перфоратор посоветуйте

Такие запросы возвращают ambiguous и до трёх вариантов.

Логика статусов:

товарный тип не определён
        → not_found

товарный тип определён, совместимых SKU несколько
        → ambiguous

после жёстких проверок остался один SKU
        → matched

товарный тип понятен, но запрошенной комбинации характеристик нет
        → not_found

Нетоварные сообщения

Приветствия и слова подскажите, дайте, нужно заранее не удаляются и не считаются признаком мусора. Сначала всегда ищется товарный evidence.

Поэтому:

статус заказа 4512 и еще нужен бур 8х160

корректно находит бур.

Только если товарных признаков нет, дополнительный словарь фраз распознаёт обращения про режим работы, оплату, адрес и статус заказа. Незнакомый нетоварный текст и известный сервисный intent по контракту одинаково возвращают not_found.

Цена и рекомендации

Слова дешевле, недорогой, бюджетный не являются идентификатором SKU. Они влияют на сортировку совместимых кандидатов по цене, но результат остаётся ambiguous, если объективно подходят несколько товаров.

Например:

шуруповерт как у макиты, только дешевле

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 примерно с такими полями:

store_id
sku
name
normalized_name
product_type
brand
model_code
unit
price
parsed_attributes
catalog_version

Часто используемые характеристики лучше хранить отдельными типизированными колонками, а редкие — в JSONB:

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-пайплайн

разобрать запрос в приложении
        ↓
определить 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.

Тесты

Запуск:

python -m pytest -q

В текущем наборе 67 тестов:

  • точные совпадения;
  • алиасы и разговорные названия;
  • DamerauLevenshtein и тяжёлые опечатки;
  • разные записи размеров;
  • отсутствующие размеры, профили и резьбы;
  • неоднозначные фасовки;
  • широкие запросы без характеристик;
  • нетоварные сообщения;
  • приоритет товарных признаков над сервисными словами;
  • исходный порядок сообщений;
  • максимум три кандидата;
  • диапазон confidence;
  • строгий формат request/response;
  • единственный endpoint /match.

Структура

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:

origin  https://git.mcbcorp.ru/Fiden/test-search-engine.git

Отправка выполняется на машине с настроенным SSH/HTTP-доступом к Gitea:

git push -u origin main
S
Description
No description provided
Readme
84 KiB
Languages
Python 100%