Реализация автоматического сопоставления товаров разных поставщиков (Matching)
Matching — задача установления соответствия между позициями разных поставщиков, описывающими один физический товар. В отличие от дедупликации (устранение явных дублей в одном каталоге), matching работает с изначально разными системами номенклатуры. У поставщика A товар называется «Смартфон Samsung S24 256Gb», у B — «SAMSUNG Galaxy S24 (256 GB) Black SM-S921B». Это один товар, но без matching система создаст две карточки.
Методы matching
Методы используются последовательно — от жёстких к мягким:
1. Точные идентификаторы
- EAN/GTIN — самый надёжный, покрывает 40–60% товаров в электронике
- Артикул производителя (MPN) + бренд — 20–30% дополнительно
- ISBN для книг, ASIN для Amazon-совместимых каталогов
2. Структурированные атрибуты
- Бренд + модель + ключевые характеристики (ёмкость, цвет, размер)
- Работает для стандартизированных категорий (техника, одежда)
3. Нечёткий текст
- Jaro-Winkler / Levenshtein на нормализованных названиях
- TF-IDF + cosine similarity на описании
- Покрывает нестандартизированные категории
4. Векторный matching (ML)
- Embeddings через sentence-transformers или OpenAI API
- Эффективен для товаров со сложными описаниями
Схема данных
-- Таблица сопоставлений
CREATE TABLE product_matches (
id BIGSERIAL PRIMARY KEY,
master_id BIGINT NOT NULL REFERENCES products(id),
supplier_id INT NOT NULL REFERENCES suppliers(id),
supplier_sku VARCHAR(255) NOT NULL,
match_method VARCHAR(30) NOT NULL, -- 'gtin', 'mpn_brand', 'fuzzy', 'ml', 'manual'
confidence FLOAT, -- 0.0–1.0
status VARCHAR(20) DEFAULT 'active', -- 'active', 'rejected', 'pending_review'
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(supplier_id, supplier_sku)
);
CREATE INDEX idx_matches_master ON product_matches(master_id);
CREATE INDEX idx_matches_confidence ON product_matches(confidence) WHERE status = 'pending_review';
Pipeline matching
class ProductMatchingPipeline
{
private array $matchers = [];
public function __construct(
private GtinMatcher $gtinMatcher,
private MpnBrandMatcher $mpnBrandMatcher,
private FuzzyMatcher $fuzzyMatcher,
private VectorMatcher $vectorMatcher,
) {
$this->matchers = [
['matcher' => $gtinMatcher, 'threshold' => 1.0, 'auto_accept' => true],
['matcher' => $mpnBrandMatcher, 'threshold' => 1.0, 'auto_accept' => true],
['matcher' => $fuzzyMatcher, 'threshold' => 0.90, 'auto_accept' => true],
['matcher' => $vectorMatcher, 'threshold' => 0.85, 'auto_accept' => false],
];
}
public function match(SupplierProductDTO $dto): MatchResult
{
foreach ($this->matchers as $config) {
$result = $config['matcher']->find($dto);
if (!$result) continue;
if ($result->confidence >= $config['threshold'] && $config['auto_accept']) {
return new MatchResult(
masterProductId: $result->productId,
confidence: $result->confidence,
method: $result->method,
status: 'active',
);
}
if ($result->confidence >= 0.70) {
// Отправить в очередь ручной проверки
return new MatchResult(
masterProductId: $result->productId,
confidence: $result->confidence,
method: $result->method,
status: 'pending_review',
);
}
}
// Не найдено — создать новый мастер-товар
return new MatchResult(masterProductId: null, confidence: 0, method: 'none', status: 'new');
}
}
GTIN matcher
class GtinMatcher
{
public function find(SupplierProductDTO $dto): ?MatchCandidate
{
if (!$dto->barcode) return null;
$normalized = $this->normalizeGtin($dto->barcode);
// Поиск в уже известных отпечатках
$fingerprint = ProductFingerprint::where('type', 'gtin')
->where('value', $normalized)
->first();
if (!$fingerprint) return null;
return new MatchCandidate(
productId: $fingerprint->product_id,
confidence: 1.0,
method: 'gtin',
);
}
private function normalizeGtin(string $raw): string
{
$digits = preg_replace('/\D/', '', $raw);
// EAN-8 → EAN-13
if (strlen($digits) === 8) {
$digits = str_pad($digits, 13, '0', STR_PAD_LEFT);
}
return $digits;
}
}
Векторный matcher через OpenAI Embeddings
class VectorMatcher
{
public function find(SupplierProductDTO $dto): ?MatchCandidate
{
$text = $this->buildText($dto);
// Получить embedding для нового товара
$vector = $this->openai->embeddings()->create([
'model' => 'text-embedding-3-small',
'input' => $text,
])->embeddings[0]->embedding;
// Поиск ближайшего соседа в pgvector
$result = DB::selectOne("
SELECT product_id, 1 - (embedding <=> :vec) AS similarity
FROM product_embeddings
WHERE 1 - (embedding <=> :vec) > 0.80
ORDER BY embedding <=> :vec
LIMIT 1
", ['vec' => '[' . implode(',', $vector) . ']']);
if (!$result) return null;
return new MatchCandidate(
productId: $result->product_id,
confidence: (float) $result->similarity,
method: 'vector',
);
}
private function buildText(SupplierProductDTO $dto): string
{
return implode(' ', array_filter([
$dto->brand,
$dto->name,
$dto->sku,
implode(' ', array_values($dto->attributes)),
]));
}
}
Для pgvector необходима расширение в PostgreSQL:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE product_embeddings (
product_id BIGINT PRIMARY KEY REFERENCES products(id),
embedding vector(1536), -- OpenAI text-embedding-3-small
updated_at TIMESTAMP
);
CREATE INDEX idx_embeddings_cosine ON product_embeddings
USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
Интерфейс ручной проверки
Товары со статусом pending_review попадают в очередь модератора. Интерфейс показывает:
- Слева — товар поставщика (название, артикул, фото)
- Справа — кандидат из каталога с процентом совпадения
- Кнопки: Подтвердить, Отклонить, Найти другой
- Горячие клавиши для скорости (→ принять, ← отклонить)
Опытный модератор обрабатывает 100–150 пар в час.
Обратная связь для улучшения модели
Каждое решение модератора — обучающий пример:
class MatchFeedbackService
{
public function recordDecision(int $matchId, string $decision, int $userId): void
{
$match = ProductMatch::findOrFail($matchId);
$match->update([
'status' => $decision === 'accept' ? 'active' : 'rejected',
'reviewed_by' => $userId,
]);
// Сохранить для дообучения
MatchTrainingExample::create([
'supplier_product_data' => $match->supplierProduct->toArray(),
'master_product_id' => $match->master_id,
'label' => $decision === 'accept' ? 1 : 0,
'confidence_was' => $match->confidence,
]);
// Если отклонён — создать новый мастер-товар
if ($decision === 'reject') {
$this->createNewMaster($match->supplierProduct);
}
}
}
Производительность
При каталоге 100 000+ позиций matching нельзя запускать перебором всех пар. Оптимизации:
- Blocking: сначала отбирать кандидатов по бренду/категории, затем матчить только внутри блока
- Batch embeddings: запрашивать векторы пачками по 100 штук за запрос к OpenAI
- pgvector IVFFlat index: approximate nearest neighbor за миллисекунды
Сроки реализации
- GtinMatcher + MpnBrandMatcher + FuzzyMatcher: 2 дня
- VectorMatcher + pgvector: 2 дня
- Pipeline + очередь ручной проверки + интерфейс: 2–3 дня
- Feedback loop + метрики: 1 день
Итого: 7–8 рабочих дней.







