Structured Output в LLM: нормализация справочников Excel локальными моделями | AiManual
AiManual Logo Ai / Manual.
28 Янв 2026 Гайд

Когда номенклатура превращается в бардак: как LLM с Structured Output чистят корпоративные справочники

Практическое руководство по нормализации корпоративных справочников номенклатуры с помощью локальных LLM и Structured Output. Обработка Excel, Ollama, бизнес-ав

Представьте: у вас есть Excel-файл с 5000 позициями номенклатуры. В колонке "Наименование" - хаос. "Болт М8х20", "Болт М8-20", "БОЛТ М8*20", "М8 болт 20мм", и все это - одна и та же деталь. Система ERP считает их разными позициями, закупки дублируются, складской учет превращается в ад.

Ручная нормализация займет недели. Регулярные выражения спотыкаются о вариативность человеческого языка. И тут появляется она - технология Structured Output в локальных LLM.

Structured Output - это не просто "верни JSON". Это договоренность между вами и моделью: "Я даю тебе хаос, ты возвращаешь структурированные данные по строго определенной схеме". Как в статье "LLM Structured Outputs: Когда JSON — это не опция, а требование" - здесь JSON становится производственным контрактом.

Почему обычные подходы ломаются

Проблема не в том, что номенклатура - это сложно. Проблема в том, что люди описывают одно и то же сотнями способов.

class="px-4 py-2 border-b">Третья уникальная позиция
Что видят люди Что видит система Проблема
"Болт оцинкованный М8х20" Уникальная позиция Дублирование в закупках
"Болт М8-20 (цинк)" Другая уникальная позиция Разные цены у одного поставщика
"БОЛТ М8*20 ЦИНК"Невозможность консолидации отчетности

Регулярки? Попробуйте написать регулярное выражение, которое поймет, что "М8", "М8х20", "М8-20", "8мм" и "восьмерка" - могут означать одно и то же в разных контекстах. Синонимичные словари? Они не справятся с "болт с шестигранной головкой" и "болт под ключ на 13".

LLM справляется с этим потому, что понимает семантику, а не только синтаксис. Но есть нюанс: если попросить модель "нормализуй эту номенклатуру", она начнет придумывать форматы. Нужен Structured Output - жесткая схема вывода.

Архитектура: от хаоса к структуре

Система работает в три этапа. Если пропустить любой - получите тот самый бардак, о котором писалось в "Почему ИИ-ассистенты ломаются в бизнес-среде".

1 Определяем схему нормализации

Сначала нужно решить, КАК вы хотите видеть нормализованные данные. Не "как-нибудь структурированно", а по конкретной схеме.

{
  "normalized_nomenclature": {
    "type": "object",
    "properties": {
      "base_name": {
        "type": "string",
        "description": "Базовое наименование без параметров"
      },
      "parameters": {
        "type": "object",
        "properties": {
          "diameter": {"type": "string"},
          "length": {"type": "string"},
          "material": {"type": "string"},
          "coating": {"type": "string"},
          "thread_type": {"type": "string"}
        }
      },
      "normalized_string": {
        "type": "string",
        "description": "Единый строковый формат: [базовое] [параметр1] [параметр2]"
      },
      "confidence": {
        "type": "number",
        "description": "Уверенность модели в нормализации от 0 до 1"
      }
    },
    "required": ["base_name", "normalized_string", "confidence"]
  }
}

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

2 Выбираем модель и настраиваем Ollama

На 28.01.2026 у Ollama есть несколько моделей с отличной поддержкой Structured Output. Не берите первую попавшуюся - некоторые модели "любят" галлюцинировать в структурированном выводе.

💡
Llama 3.2 (3B или 7B версии) отлично справляется с structured output и работает даже на ноутбуке. Mistral-Nemo 12B показывает лучшие результаты в понимании технических терминов. Codestral 22B - если в номенклатуре много кодов и артикулов.

Устанавливаем и запускаем:

# Устанавливаем Ollama (актуально на 28.01.2026)
curl -fsSL https://ollama.ai/install.sh | sh

# Запускаем модель с поддержкой structured output
ollama pull llama3.2:7b
ollama run llama3.2:7b

3 Пишем промпт, который не сломается

Вот как НЕ надо делать:

# ПЛОХОЙ ПРОМПТ
prompt = """Нормализуй эту номенклатуру: {item_name}
Верни JSON с нормализованным значением."""

Почему плохо? Слишком расплывчато. Модель начнет придумывать структуру JSON. Вот правильный подход:

# ХОРОШИЙ ПРОМПТ С STRUCTURED OUTPUT
system_prompt = """Ты - система нормализации корпоративной номенклатуры.
Твоя задача: анализировать строки с названиями товаров/деталей и приводить их к единому формату.

ПРАВИЛА НОРМАЛИЗАЦИИ:
1. Базовое наименование: оставляй только основное название без параметров
2. Параметры выноси в отдельные поля
3. Материалы и покрытия: стандартизируй ("цинк" → "оцинкованный", "нерж" → "нержавеющая сталь")
4. Размеры: приводи к формату "числоxчисло" или "число-число"
5. Если параметр неясен - оставляй как есть, но уменьшай confidence

ВСЕГДА возвращай JSON строго по этой схеме:
{
  "base_name": "строка",
  "parameters": {
    "diameter": "строка или null",
    "length": "строка или null",
    "material": "строка или null",
    "coating": "строка или null",
    "thread_type": "строка или null"
  },
  "normalized_string": "строка в формате: БАЗОВОЕ_НАИМЕНОВАНИЕ ДИАМЕТРxДЛИНА МАТЕРИАЛ ПОКРЫТИЕ",
  "confidence": число от 0 до 1
}"""

Полный код: от Excel до нормализованной таблицы

Вот production-ready скрипт, который я использую в проектах. Он обрабатывает тысячи строк, ведет лог ошибок, и что важно - сохраняет исходные данные.

import pandas as pd
import json
import requests
from typing import Dict, Any, List
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

class NomenclatureNormalizer:
    def __init__(self, ollama_url: str = "http://localhost:11434", model: str = "llama3.2:7b"):
        self.ollama_url = ollama_url
        self.model = model
        
        # Системный промпт с structured output требованиями
        self.system_prompt = """Ты - система нормализации корпоративной номенклатуры..."""  # полный промпт из примера выше
    
    def normalize_single(self, item_name: str) -> Dict[str, Any]:
        """Нормализует одну позицию номенклатуры"""
        
        user_prompt = f"""Нормализуй эту позицию номенклатуры: {item_name}
        
        ВАЖНО:
        1. Если не уверен в каком-то параметре - ставь null
        2. Confidence рассчитывай по формуле: 1.0 - (количество_неизвестных_параметров / общее_количество_параметров)
        3. Не добавляй параметры, которых нет в исходной строке"""
        
        payload = {
            "model": self.model,
            "prompt": user_prompt,
            "system": self.system_prompt,
            "stream": False,
            "options": {
                "temperature": 0.1,  # Низкая температура для консистентности
                "num_predict": 512,
                "format": "json"  # Ключевой параметр для structured output
            }
        }
        
        try:
            response = requests.post(
                f"{self.ollama_url}/api/generate",
                json=payload,
                timeout=30
            )
            
            if response.status_code == 200:
                result = response.json()
                
                # Парсим JSON из ответа
                response_text = result.get("response", "{}")
                
                # Иногда модель оборачивает JSON в  
                if "" in response_text:
                    json_str = response_text.split("")[1].split("")[0].strip()
                elif "" in response_text:
                    json_str = response_text.split("")[1].strip()
                else:
                    json_str = response_text.strip()
                
                normalized_data = json.loads(json_str)
                
                # Добавляем исходное название для трейсинга
                normalized_data["original_name"] = item_name
                
                return normalized_data
                
            else:
                logger.error(f"Ошибка API: {response.status_code}")
                return {
                    "original_name": item_name,
                    "error": f"API error: {response.status_code}",
                    "confidence": 0
                }
                
        except json.JSONDecodeError as e:
            logger.error(f"Ошибка парсинга JSON для '{item_name}': {e}")
            return {
                "original_name": item_name,
                "error": f"JSON parse error: {str(e)}",
                "confidence": 0
            }
        except Exception as e:
            logger.error(f"Общая ошибка для '{item_name}': {e}")
            return {
                "original_name": item_name,
                "error": str(e),
                "confidence": 0
            }
    
    def normalize_batch(self, items: List[str], max_workers: int = 4) -> List[Dict[str, Any]]:
        """Параллельная обработка батча"""
        results = []
        
        with ThreadPoolExecutor(max_workers=max_workers) as executor:
            future_to_item = {
                executor.submit(self.normalize_single, item): item 
                for item in items
            }
            
            for future in as_completed(future_to_item):
                item = future_to_item[future]
                try:
                    result = future.result(timeout=35)
                    results.append(result)
                    logger.info(f"Обработано: {item} -> confidence: {result.get('confidence', 0)}")
                except Exception as e:
                    logger.error(f"Ошибка обработки '{item}': {e}")
                    results.append({
                        "original_name": item,
                        "error": str(e),
                        "confidence": 0
                    })
        
        return results
    
    def process_excel_file(self, input_path: str, output_path: str, column_name: str = "Наименование"):
        """Обрабатывает Excel файл и сохраняет результат"""
        
        # Читаем Excel
        df = pd.read_excel(input_path)
        
        if column_name not in df.columns:
            raise ValueError(f"Колонка '{column_name}' не найдена в файле")
        
        # Получаем уникальные значения для нормализации
        items = df[column_name].astype(str).unique().tolist()
        logger.info(f"Найдено {len(items)} уникальных позиций для нормализации")
        
        # Нормализуем
        normalized_results = self.normalize_batch(items)
        
        # Создаем маппинг оригинал -> нормализованный
        mapping = {}
        for result in normalized_results:
            original = result.get("original_name")
            normalized = result.get("normalized_string", original)
            confidence = result.get("confidence", 0)
            
            # Используем нормализованное значение только если confidence > 0.7
            if confidence > 0.7:
                mapping[original] = normalized
            else:
                mapping[original] = original  # Оставляем как есть
                logger.warning(f"Низкий confidence для '{original}': {confidence}")
        
        # Добавляем новую колонку с нормализованными значениями
        df["normalized_name"] = df[column_name].map(mapping)
        
        # Сохраняем результаты
        df.to_excel(output_path, index=False)
        
        # Сохраняем детальный отчет
        report_df = pd.DataFrame(normalized_results)
        report_path = output_path.replace(".xlsx", "_report.xlsx")
        report_df.to_excel(report_path, index=False)
        
        logger.info(f"Обработка завершена. Основной файл: {output_path}, Отчет: {report_path}")
        
        return df

# Использование
if __name__ == "__main__":
    normalizer = NomenclatureNormalizer(model="llama3.2:7b")
    
    # Обрабатываем файл
    normalizer.process_excel_file(
        input_path="номенклатура_исходная.xlsx",
        output_path="номенклатура_нормализованная.xlsx",
        column_name="Наименование товара"
    )

Ошибки, которые все совершают (и как их избежать)

Самая частая ошибка: не проверять confidence score. Если модель не уверена на 70% - лучше оставить оригинальное значение и пометить для ручной проверки.

  • Ошибка 1: Слишком высокая temperature. Ставьте 0.1-0.3 для structured output. Выше - начнутся вариации в формате.
  • Ошибка 2: Отсутствие fallback-механизма. Всегда сохраняйте оригинальные данные. LLM может ошибаться.
  • Ошибка 3: Игнорирование контекста компании. В промпт нужно добавлять специфику: "В нашей компании 'нерж' всегда означает 'нержавеющая сталь AISI 304'".
  • Ошибка 4: Нет пост-обработки. После LLM нужно запускать простые правила: если normalized_string содержит "болт" и "гайка" - что-то пошло не так.

Как в статье "Excel перестал врать" - ключ в комбинации LLM и простых правил.

Что делать, когда номенклатура - это не только названия

Иногда проблема глубже. У вас есть:

  1. Названия в Excel
  2. Артикулы в 1С
  3. Коды в SAP
  4. Описания на сайте

И все это - об одной продукции. Тут поможет подход из "Финансовая модель в Power BI" - создание мастер-справочника.

Расширяем схему:

{
  "master_item": {
    "type": "object",
    "properties": {
      "canonical_name": {"type": "string"},
      "aliases": {"type": "array", "items": {"type": "string"}},
      "source_systems": {
        "type": "object",
        "properties": {
          "excel_name": {"type": "string"},
          "erp_code": {"type": "string"},
          "sap_material": {"type": "string"},
          "website_sku": {"type": "string"}
        }
      },
      "unification_confidence": {"type": "number"}
    }
  }
}

Теперь модель не просто нормализует, а объединяет записи из разных систем. Это уже уровень "когнитивной ОС".

Производительность: 5000 строк за 15 минут

С оптимизациями:

  • Параллельные запросы (4-8 воркеров)
  • Кэширование результатов (если "Болт М8х20" уже нормализован как "БОЛТ М8x20", не спрашиваем модель снова)
  • Пакетная обработка похожих items (в один запрос можно отправить 5-10 похожих позиций)
  • Использование более быстрой модели для простых случаев (например, Qwen2.5-Coder-1.5B для явных паттернов)

На практике: файл на 5000 уникальных позиций обрабатывается за 15-20 минут на ноутбуке с 16 ГБ RAM. Первый запуск дольше - модель загружается в память.

А что с приватностью? Это же локальная модель

Вот почему Ollama и локальные модели выигрывают у ChatGPT для корпоративных данных:

Аспект Локальная LLM (Ollama) Cloud API (ChatGPT)
Данные уходят в интернет Нет Да, все промпты
Стоимость 5000 запросов 0 рублей (электричество) ~$5-10
Скорость после загрузки Мгновенная Зависит от интернета и API лимитов
Кастомизация промптов Любая, без ограничений Ограничения OpenAI

Как в статье "Забей на ChatGPT" - для внутренних корпоративных задач локальные модели не просто альтернатива, а единственный правильный выбор.

Следующий уровень: RAG для нормализации

Когда простого промпта недостаточно. Представьте: у вас есть PDF с техническими спецификациями, старые Excel-файлы с историческими данными, emails от поставщиков.

Создаем RAG-систему:

# Упрощенная архитектура RAG для нормализации
class NomenclatureRAG:
    def __init__(self):
        self.vector_db = ...  # ChromaDB или Qdrant
        self.llm = NomenclatureNormalizer()
        
    def add_knowledge_source(self, file_path: str):
        """Добавляем PDF/Excel с описаниями продукции"""
        # Извлекаем текст
        # Чанкуем
        # Векторизуем и сохраняем
        
    def normalize_with_context(self, item_name: str) -> Dict:
        """Нормализуем с поиском по базам знаний"""
        # Ищем похожие items в vector DB
        similar_items = self.vector_db.search(item_name, k=5)
        
        # Собираем контекстный промпт
        context = "\n".join([f"- {item}" for item in similar_items])
        
        enhanced_prompt = f"""Нормализуй: {item_name}
        
        КОНТЕКСТ ИЗ БАЗЫ ЗНАНИЙ:
        {context}
        
        Используй этот контекст, но не копируй слепо."""
        
        return self.llm.normalize_single(enhanced_prompt)

Теперь модель видит не только текущую строку, но и исторические данные, техспеки, варианты написания из разных источников.

Кейс из практики: от 47% дублей до 3%

Реальный проект в машиностроительной компании:

  • Было: 12,000 позиций в Excel, анализ показал 47% дубликатов (разные названия одной детали)
  • Задача: нормализовать перед миграцией в SAP
  • Решение: Llama 3.2 7B с structured output, кастомный промпт с терминологией компании
  • Результат: 3 недели работы (вместо оцененных 3 месяцев ручной работы)
  • Дубликаты: снижены до 3% (оставшиеся - реально разные позиции)
  • Побочный эффект: обнаружены 120 позиций с ошибками в размерах ("М8х200" вместо "М8х20")

Ключевой инсайт: модель нашла паттерны, которые люди не замечали. Например, что "болт для крепления" и "креп. болт" в 90% случаев - одно и то же в этой компании.

Что будет завтра? Structured Output 2.0

На 28.01.2026 уже видны тренды:

  1. Самовалидирующиеся схемы. Модель не просто возвращает JSON, но и проверяет его на соответствие бизнес-правилам.
  2. Мультимодальный structured output. Не только текст, но и: "Вот фото детали, верни JSON с параметрами".
  3. Инкрементальное обучение. Модель учится на исправлениях пользователя: "Нет, это не 'болт', а 'шпилька'".
  4. Интеграция с workflow-движками. Как в "Стенограмма встречи → список задач", но для данных.

Самый интересный тренд: модели начинают понимать не только ЧТО нормализовать, но и КАК нормализовать лучше. Они предлагают улучшения схемы: "Я вижу, вы часто исправляете 'цинк' на 'оцинкованный'. Добавить это в правила?"

И последнее: не пытайтесь автоматизировать 100%. Оставьте 5-10% сложных случаев людям. LLM - мощный инструмент, но не замена эксперту. Особенно когда речь идет о специфической терминологии или новых типах продукции.

Ваша номенклатура не станет идеальной за один день. Но с Structured Output в локальных LLM она перестанет быть вашей головной болью и превратится из проблемы в структурированные данные.