Реализация автоматической категоризации товаров (AI)
Когда каталог формируется из нескольких источников — поставщики, XML-фиды, ручной ввод — товары приходят с разными структурами данных и часто без правильной категории. Расставлять их по разделам вручную при сотнях позиций в день нереально.
Автоматическая категоризация через языковую модель работает иначе, чем правила или regexp. Модель понимает смысл, а не только ключевые слова: «беспроводные наушники с шумоподавлением ANC» и «TWS earbuds noise cancelling» попадут в одну категорию без явного маппинга.
Два режима категоризации
Режим 1: классификация в заданное дерево категорий. Передаём модели список допустимых категорий и просим выбрать наиболее подходящую. Детерминированный результат, легко валидировать.
Режим 2: предложение новых категорий. Модель сама предлагает название категории на основе семантики товара. Используется при первичном построении каталога или выявлении «осиротевших» товаров.
На практике нужен первый режим с фолбэком на второй для товаров, не попавших ни в одну категорию.
Классификация в существующее дерево
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 = `
Classify this product into the most appropriate category.
Product:
- Name: ${product.name}
- Description: ${product.description?.slice(0, 300) ?? "—"}
- Brand: ${product.brand ?? "—"}
- Supplier category: ${product.supplierCategory ?? "—"}
- Attributes: ${JSON.stringify(product.attributes ?? {}).slice(0, 200)}
Available categories (id: path):
${categoryList}
Return JSON:
{
"categoryId": "the id from the list above",
"confidence": 0.0-1.0,
"reasoning": "one sentence why"
}
If no category fits well, use the closest parent category and set confidence below 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 ? ` by ${p.brand}` : "") +
(p.supplierCategory ? ` (supplier: ${p.supplierCategory})` : "")
)
.join("\n");
const prompt = `
Classify each product into one of the categories. Return JSON array.
Categories:
${categoryList}
Products:
${productList}
Return: [{"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 `\nExamples of products in this category: ${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 days'
GROUP BY categorization_status;
Если доля needs_review растёт — возможно, появились новые типы товаров, которые не покрываются текущим деревом категорий. Это сигнал к расширению каталога.







