Реалізація автоматичного сопоставлення товарів різних постачальників (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 робочих днів.







