Реалізація автоматичної категоризації товарів (AI)
Коли каталог формується з кількох джерел — постачальники, XML-фіди, ручний ввід — товари приходять з різними структурами даних і часто без правильної категорії. Розставляти їх по розділам вручну при сотнях позицій на день нереально.
Автоматична категоризація через мовну модель працює інакше, ніж правила або regexp. Модель розуміє сенс, а не тільки ключові слова: «бездротові навушники з шумозаглушенням ANC» та «TWS earbuds noise cancelling» потраплять в одну категорію без явного маппінгу.
Два режими категоризації
Режим 1: класифікація в задане дерево категорій. Передаємо моделі список допустимих категорій та просимо вибрати найбільш підходящу. Детерміністичний результат, легко валідувати.
Режим 2: пропозиція нових категорій. Модель сама пропонує назву категорії на основі семантики товару. Використовується при первинному побудові каталогу або виявленні «осиротевших» товарів.
На практиці потрібен перший режим з fallback'ом на другий для товарів, що не потрапили в жодну категорію.
Класифікація в існуюче дерево
interface CategoryTree {
id: string;
name: string;
path: string; // "Електроніка / Аудіо / Навушники"
children?: CategoryTree[];
}
async function classifyProduct(
product: RawProduct,
categories: CategoryTree[]
): Promise<{ categoryId: string; confidence: number; reasoning: string }> {
// Плоский список шляхів для промпту
const categoryList = flattenCategories(categories)
.map((c) => `${c.id}: ${c.path}`)
.join("\n");
const prompt = `
Класифікуй цей товар в найбільш підходящу категорію.
Товар:
- Назва: ${product.name}
- Опис: ${product.description?.slice(0, 300) ?? "—"}
- Бренд: ${product.brand ?? "—"}
- Категорія постачальника: ${product.supplierCategory ?? "—"}
- Атрибути: ${JSON.stringify(product.attributes ?? {}).slice(0, 200)}
Доступні категорії (id: path):
${categoryList}
Вихід JSON:
{
"categoryId": "id зі списку вище",
"confidence": 0.0-1.0,
"reasoning": "одна фраза чому"
}
Якщо жодна категорія добре не підходить, використовуй найближчу батьківську категорію та встанови confidence нижче 0.5.
`.trim();
const response = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: prompt }],
response_format: { type: "json_object" },
temperature: 0,
});
return JSON.parse(response.choices[0].message.content!);
}
temperature: 0 — для завдань класифікації потрібна відтворюваність, а не креативність.
Батч-обробка з розумним промптом
Для економії токенів та прискорення — класифікуємо кілька товарів за один запит:
async function classifyBatch(
products: RawProduct[],
categories: CategoryTree[]
): Promise<Map<string, ClassificationResult>> {
const categoryList = flattenCategories(categories)
.map((c) => `${c.id}: ${c.path}`)
.join("\n");
const productList = products
.map(
(p, i) =>
`[${i}] "${p.name}"` +
(p.brand ? ` від ${p.brand}` : "") +
(p.supplierCategory ? ` (постачальник: ${p.supplierCategory})` : "")
)
.join("\n");
const prompt = `
Класифікуй кожен товар в одну з категорій. Поверни JSON масив.
Категорії:
${categoryList}
Товари:
${productList}
Вихід: [{"index": 0, "categoryId": "...", "confidence": 0.0-1.0}, ...]
`.trim();
const response = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: prompt }],
response_format: { type: "json_object" },
temperature: 0,
max_tokens: 1000,
});
const results: Array<{ index: number; categoryId: string; confidence: number }> =
JSON.parse(response.choices[0].message.content!).results ?? [];
const map = new Map<string, ClassificationResult>();
for (const r of results) {
const product = products[r.index];
if (product) {
map.set(product.id, { categoryId: r.categoryId, confidence: r.confidence });
}
}
return map;
}
10–20 товарів в одному запиті — розумний батч. Більше — промпт стає занадто довгим і якість падає.
Робітник з чергою
const categorizationWorker = new Worker(
"categorization",
async (job) => {
const { productIds } = job.data;
const products = await db.products.findMany({
where: { id: { in: productIds } },
});
const categories = await db.categories.findAll({ active: true });
const results = await classifyBatch(products, categories);
for (const [productId, result] of results) {
await db.products.update({
where: { id: productId },
data: {
categoryId: result.confidence >= 0.7 ? result.categoryId : null,
suggestedCategoryId: result.categoryId,
categorizationConfidence: result.confidence,
categorizationStatus:
result.confidence >= 0.7 ? "auto_assigned" : "needs_review",
categorizedAt: new Date(),
},
});
}
},
{ connection: redisConnection, concurrency: 3 }
);
Товари з confidence < 0.7 потрапляють у чергу ревю — категорію їм назначає менеджер, і це додатково навчає систему через few-shot приклади.
Few-shot навчання на прикладах з каталогу
Коли менеджер вручну виправляє категорію, це цінні дані. Накопичуємо їх та підставляємо у промпт:
async function getExamplesForCategory(categoryId: string, limit = 5): Promise<string> {
const examples = await db.products.findMany({
where: { categoryId, categorizationStatus: "manually_confirmed" },
select: { name: true, brand: true },
take: limit,
});
if (examples.length === 0) return "";
return `\nПриклади товарів у цій категорії: ${examples.map((e) => `"${e.name}"`).join(", ")}`;
}
Через 2–3 тижні роботи системи з ревю точність автоматичної класифікації в конкретному каталозі виростає до 90%+ — модель бачить реальні приклади вашого каталогу.
Мониторинг якості
SELECT
categorization_status,
AVG(categorization_confidence) as avg_confidence,
COUNT(*) as count
FROM products
WHERE categorized_at > NOW() - INTERVAL '7 дней'
GROUP BY categorization_status;
Якщо частка needs_review зростає — можливо, з'явилися нові типи товарів, які не покриваються поточним деревом категорій. Це сигнал до розширення каталогу.







