Поиск дублей в 1С и сопоставление номенклатуры поставщика через ИИ

Поиск дублей в 1С упирается в одну вещь: типовая обработка сравнивает строки. Для неё «Болт М8х40 оц.» и «болт оцинкованный М840» — две разные позиции, поэтому дубли номенклатуры спокойно живут в справочнике годами и всплывают при первой же инвентаризации. Та же стена встаёт при загрузке прайса: сопоставление номенклатуры поставщика* с вашим каталогом делают руками, потому что поставщик пишет наименования по-своему, а «похоже» и «то же самое» для строкового сравнения не связаны никак.

Ниже — рабочая архитектура сервиса, который закрывает обе задачи одним конвейером: векторный поиск отбирает кандидатов, LLM выбирает лучшего и возвращает свою уверенность, а порог по этой уверенности решает, что проводится автоматически, а что уходит человеку. Код взят из моего сервиса сопоставления на FastAPI и из проекта классификации каталога; оба писались под реальные торговые базы.

Тем, кому нужен свой сервис на этой архитектуре, а не общее представление, — курс «ИИ для 1С» как раз про сборку таких вещей под конкретный каталог.

Почему сравнение строк не решает задачу в принципе?

Вот как выглядит обычная офлайн-выгрузка номенклатуры из 1С, тестовая база, с которой я работаю:

[
  {"Наименование": "Секатор кустарниковый 500мм сталь С-48К /1шт/уп",
   "Артикул ": "С-48К", "Код": "00000047680", "Цена": 661},
  {"Наименование": "Секатор кустарниковый 550мм сталь с зубч. усилителем С-56К /1шт/уп",
   "Артикул ": "С-56К", "Код": "00000047681", "Цена": 816}
]

Обратите внимание на две детали. Ключ "Артикул " — с пробелом на конце: так его отдала выгрузка, и первый же наивный парсер по row["Артикул"] упадёт. А наименование — это не название товара, а слипшаяся строка из типа, размера, материала, артикула и фасовки. Ровно так номенклатура и выглядит в большинстве баз.

Теперь представьте, что тот же секатор приходит из прайса поставщика строкой «Секатор кустарниковый 0,5 м, сталь, арт. C-48K». Что сделает каждый из привычных механизмов:

  • точное сравнение — промах;
  • поиск по подстроке — промах, «500мм» и «0,5 м» не пересекаются ни одним символом;
  • нечёткое сравнение (расстояние Левенштейна, триграммы) — выдаст какую-то похожесть, но с той же похожестью подтянет соседний «Секатор кустарниковый 550мм», потому что различие там в один символ;
  • сравнение по артикулу — сломается на латинской C вместо кириллической С, и это самый обидный класс ошибок, потому что визуально строки идентичны.

Причина у всех промахов одна. Строковые метрики отвечают на вопрос «насколько похоже написано». Задача же — «насколько это один и тот же товар». Эти вопросы не совпадают: «0,5 м» и «500мм» написаны совершенно по-разному и означают одно, а «500мм» и «550мм» написаны почти одинаково и означают разное.

Что меняет векторное представление?

Эмбеддинг переводит наименование в вектор, и близость векторов отражает близость смысла, а не написания. «Секатор кустарниковый 0,5 м» встаёт рядом с «Секатор кустарниковый 500мм», хотя общих подстрок у них почти нет. Как это устроено внутри, какую модель эмбеддингов брать и как читать score, разобрано отдельно в статье семантический поиск в 1С на эмбеддингах и FAISS; здесь я на это опираюсь как на готовую базу.

Важно сразу понимать границу применимости. Вектор хорошо ловит смысл и плохо ловит точность. «Секатор 500мм» и «Секатор 550мм» окажутся почти на одинаковом расстоянии от запроса, потому что смысл у них действительно почти один: разница в 50 мм для модели эмбеддингов — шум, а для склада это разные позиции с разной ценой. Отсюда главный вывод, вокруг которого строится вся архитектура: вектор не даёт ответа, он даёт список кандидатов. Финальный выбор нужно делать другим инструментом.

Архитектура: четыре шага и один порог

Конвейер устроен так:

  1. Выгрузка каталога из 1С — плоский список позиций (код, наименование, артикул, цена).
  2. Векторизация каталога — один раз при загрузке, база сохраняется на диск.
  3. Поиск top-N кандидатов по строке поставщика — быстро, дёшево, без LLM.
  4. LLM-реранкер — модель смотрит на запрос и на N кандидатов, выбирает одного и возвращает confidence.
  5. Порог по confidence — выше порога позиция сопоставляется автоматически, ниже уходит человеку, совсем низкая отбрасывается как «нет соответствия».

Ключевая идея разделения: дешёвый поиск сужает каталог с десятков тысяч позиций до трёх, а дорогая модель работает только на этих трёх. Гонять LLM по всему справочнику на каждую строку прайса невозможно ни по времени, ни по деньгам.

Шаг 1. Что класть в векторизуемую строку

Каталог превращается в документы. В моём сервисе это выглядит так:

for product in products:
    documents.append(
        Document(
            page_content=f"{product.name}".lower(),
            metadata={
                "id": f"{product.id}",
                "full_name": f"{product.name}",
            },
        )
    )
    ids.append(str(product.id))

await self.generate(documents=documents, ids=ids, user_id=user_id)

Здесь три решения, каждое из которых стоит перенести к себе.

Разделение page_content и metadata. Вектор считается только по page_content — по нему ищут. В metadata едет то, что нужно вернуть: идентификатор позиции 1С и исходное наименование в нормальном виде. Приводить к нижнему регистру имеет смысл только в векторизуемой части, показывать человеку надо оригинал.

Явные ids. База строится с идентификаторами из 1С, а не с автоматическими. Это то, что потом позволит точечно обновить или удалить позицию, не пересобирая всю базу.

Своя база на пользователя. У каждого клиента сервиса свой каталог, поэтому имя индекса содержит user_id, а перед перегенерацией старые файлы удаляются с диска. Иначе вы получите смесь из двух каталогов и необъяснимые сопоставления.

Про состав строки. Минимальный рабочий вариант — одно наименование, и его уже достаточно, чтобы конвейер заработал. Дальше строку обогащают: артикул, ключевые характеристики, категория, иногда бренд отдельным словом. Правило простое: в строку идёт то, что помогает отличить эту позицию от соседней, и не идёт то, что одинаково у половины каталога. Единица измерения, ставка НДС и склад в векторизуемой строке только размывают смысл.

Шаг 2. Поиск кандидатов

Сам поиск занимает три строки, вся тонкость в том, что происходит вокруг:

product_embedding.load_user_database(user_id=user.id)

docs = product_embedding.db.similarity_search(
    query=request.query.lower(),
    k=3,
)

k=3 — не догма, но осмысленная точка старта. Больше кандидатов означает более длинный промпт реранкера и больше шансов, что модель ухватится за лишнее; меньше — риск, что правильная позиция вообще не попадёт в выборку. Практический способ выбрать k: взять размеченный эталон и посмотреть, при каком k правильный ответ попадает в кандидаты почти всегда. Дальше увеличивать k смысла нет, это уже работа для реранкера.

И запомните свойство векторного поиска, которое ломает первые прототипы: similarity_search всегда вернёт k документов. Если в каталоге нет ничего похожего, вы получите три случайные позиции с плохими значениями score, а не пустой ответ. Само по себе наличие результата ничего не значит.

Шаг 3. LLM-реранкер и честная уверенность

Реранкер получает запрос и список кандидатов и обязан вернуть строго структурированный ответ. Схема ответа и есть основной инструмент управления моделью:

class LLMRerankResult(BaseModel):
    best_index: int = Field(
        description="Индекс наиболее подходящего товара из списка (1, 2 или 3)"
    )
    confidence: float = Field(
        description="""Уверенность в соответствии выбранного товара запросу (от 0.0 до 1.0).

ВАЖНО: Оценивай РЕАЛЬНОЕ семантическое сходство товара и запроса:
- 0.9-1.0: Почти точное совпадение названия, бренда и всех характеристик
- 0.7-0.9: Совпадает бренд, тип товара и большинство ключевых характеристик
- 0.5-0.7: Совпадает тип товара и бренд, но есть отличия в характеристиках
- 0.3-0.5: Совпадает только общий тип товара, бренд отличается
- 0.1-0.3: Слабое сходство — разные товары одной категории
- 0.0-0.1: Товары из совершенно разных категорий

НЕ завышай уверенность для слабо связанных товаров!"""
    )

Обратите внимание, где живёт шкала. Не в коде, который потом интерпретирует число, а в описании поля, которое уходит модели вместе с JSON Schema. Без такой шкалы модель ставит 0.9 практически всему, что выбрала: она уже приняла решение и склонна его защищать. Со шкалой и явным запретом на завышение оценки становятся хоть сколько-нибудь разделяющими.

Второй приём того же рода: примеры прямо в системном промпте, привязанные к вашей товарной специфике:

- 0.9-1.0: ПОЧТИ ИДЕНТИЧНЫЕ товары
  Пример: запрос "Молоко Простоквашино 3.2% 1л" → товар "Молоко Простоквашино отборное 3.2% 930мл"
- 0.3-0.5: ОТДАЛЁННО ПОХОЖИЕ (только тип товара совпадает, бренд другой)
  Пример: запрос "Молоко Простоквашино" → товар "Молоко Домик в деревне"

Дальше сервис просто разворачивает индекс обратно в позицию каталога и отдаёт наружу product_id и confidence. Ответ вида {"product_id": "2", "confidence": 0.95} — это уже то, что 1С может принять и разложить по своим регистрам.

Где заканчивается автоматика и начинается ручная проверка?

Порог — не настройка, а бизнес-решение. В сервисе базовое правило такое: при confidence ниже 0.3 сопоставление считается несостоявшимся и возвращается пустой результат. Всё, что выше, дальше делится ещё раз, и вот эту вторую границу каждый ставит себе сам, исходя из цены ошибки.

Где ИИ ошибается на номенклатуре чаще всего:

  • типоразмеры и объёмы. «930 мл» вместо «1 л», «М8х40» вместо «М8х45». Модель видит почти идентичный товар, склад видит другую позицию;
  • аналоги и заменители. Поставщик пишет «аналог 0233», и модель уверенно ведёт на оригинал, потому что смысл действительно близкий;
  • фасовка и упаковка. /12шт/уп в конце наименования из примера выше — это разница между штукой и коробкой, и в цене она принципиальна;
  • бренды-омонимы и латиница вперемешку с кириллицей. Тот же случай с C-48K и С-48К;
  • пустая полка. Позиции в каталоге просто нет, но кандидаты нашлись, и модель обязана выбрать одного из трёх. Именно эти случаи и должен отсекать нижний порог.

Отсюда практическое правило: автоматически проводить можно только верхний диапазон, и только там, где ошибка дёшево исправляется. Всё остальное уходит человеку в очередь на подтверждение, отсортированную по убыванию уверенности. Такой список разбирается в разы быстрее ручного сопоставления, потому что человек не ищет позицию, а нажимает «да» или «нет» напротив предложенной.

Разбор архитектуры целиком, вместе с подбором порогов под конкретный каталог и связкой сервиса с 1С, мы делаем на курсе «ИИ для 1С»: там же лежит код сервиса, а не только его скелет.

Прайс поставщика: подготовка входа

Вторая половина работы: привести прайс к тому же плоскому виду, что и каталог. Excel от поставщика почти никогда не начинается с заголовков: сверху шапка с реквизитами, логотип, пустые строки. Поэтому файл сначала читают вообще без заголовков и смотрят глазами, где начинаются данные:

df = pd.read_excel(file_path, header=None)

for idx in range(7, len(df)):
    row = df.iloc[idx]
    product_id, product_name, product_price = row[0], row[4], row[11]

    if pd.isna(product_id) or pd.isna(product_name) or pd.isna(product_price):
        continue

    products.append(ProductItem(
        id=str(product_id).strip(),
        name=str(product_name).strip(),
        price=str(product_price).strip(),
    ))

Выглядит примитивно, и это осознанно. Номера колонок и номер первой строки данных — это то, что настраивается на конкретного поставщика один раз и живёт в его профиле. Попытка угадать структуру автоматически хорошо работает на демонстрации и разваливается на объединённых ячейках, промежуточных итогах и строках-разделителях категорий. Практичнее держать по одному профилю разбора на поставщика и валидировать результат: сколько строк прочитано, сколько отброшено, есть ли цены-нули.

Второй контур: категории, которые нельзя размечать моделью

Сопоставление — это поиск по каталогу. Отдельная задача — разложить каталог по категориям: и чтобы искать внутри группы, и чтобы ловить дубли внутри одной группы, а не по всей базе.

Здесь у LLM обнаруживается проблема, из-за которой её нельзя поставить в поток. Один вызов на позицию занимает секунды. Для каталога в десятки тысяч позиций это часы и заметные деньги, а новые товары приходят каждый день. Поэтому контур строится в два уровня: LLM размечает базу один раз, а на её разметке обучается быстрая локальная модель, которая дальше работает потоком.

Разметка через LLM интересна приёмом с динамической схемой. Список категорий заранее не известен, он растёт по ходу:

literal_type = Literal[tuple(known_categories)]

return create_model(
    "GroupsData",
    category=(Optional[literal_type], Field(default=None, description=category_desc)),
    new_category=(Optional[str], Field(default=None, description=new_category_desc)),
)

Схема пересобирается перед каждым товаром под текущий список известных категорий. Модель обязана либо выбрать ровно одно значение из перечисления, либо оставить поле пустым и предложить новую категорию в отдельном поле. Свободный текст в category физически невозможен, потому что это Literal, а не строка. При этом системный промпт остаётся неизменным на всех итерациях, а меняющийся список уезжает в JSON Schema — так локальный сервер переиспользует кэш префикса и отвечает быстрее.

Затем на этой разметке обучается FastText — линейная модель с subword-нграммами. На моей базе она даёт accuracy 0.933 после автоподбора гиперпараметров (против 0.865 на ручных), весит около 14 MB, грузится примерно за 50 мс и предсказывает категорию за десятки микросекунд. Это и есть та скорость, которая нужна, когда категорию надо проставить в момент загрузки прайса.

Пара практических деталей, которые сильно влияют на результат. Категории с одним-двумя товарами из обучения выбрасываются целиком: модель их не выучивает, а macro-метрики они тянут вниз. Цифры в наименованиях, наоборот, сохраняются: артикул «21214» несёт сигнал о товаре, и subword-нграммы умеют его обобщать. Пунктуация схлопывается в пробелы, чтобы «5w-30», «5w/30» и «5w 30» не расходились в три разных токена.

Канонизация словаря категорий

Побочный эффект динамического перечисления: словарь категорий монотонно растёт и обрастает дублями вида «Резонаторы глушителя» и «Резонаторы глушителей». Лечится это вторым проходом LLM, тоже в два раунда.

В первом раунде категории идут батчами, отсортированные по убыванию числа товаров: крупные категории-якоря равномерно распределяются по батчам и притягивают к себе мелкие дубли. Модель возвращает пары «исходная категория → каноническая». Во втором раунде тот же проход делается уже по списку канонических имён, чтобы склеить синонимы, разъехавшиеся по разным батчам первого раунда.

Две вещи, без которых этот этап превращается в кашу. Матчинг ответа делается строго по исходной строке (с запасным вариантом без учёта регистра), а всё, что модель не вернула, остаётся без изменений — молчание модели не должно стирать категорию. И обязательно пишется audit-файл вида «оригинал → канон → сколько товаров», который просматривают глазами: именно там видно, что модель склеила лишнего.

Как мерить качество сопоставления?

Без измерения вся конструкция держится на вере. Минимальный набор, который стоит завести до того, как сервис пойдёт в работу:

  • Размеченный эталон. Двести-триста реальных строк прайса, для которых человек руками указал правильную позицию каталога, включая случаи «в каталоге такого нет». Без последней категории вы никогда не измерите ложные срабатывания.
  • Recall кандидатов — доля случаев, когда правильная позиция попала в top-N. Это потолок всего конвейера: чего нет в кандидатах, того реранкер не выберет никогда. Если recall низкий, чините векторизуемую строку, а не промпт.
  • Точность на автопроводке — доля правильных сопоставлений среди тех, что прошли верхний порог. Именно эта цифра определяет, можно ли вообще проводить автоматически.
  • Доля ручной очереди — сколько процентов прайса ушло человеку. Это метрика экономии, ради которой всё затевалось.
  • Agreement-rate между контурами — по категориям удобно сверять быструю модель с LLM-разметкой построчно и глазами смотреть расхождения. Там же обычно обнаруживаются кривые категории, а не ошибки модели.

Пороги калибруются по этому же эталону, и калибруются заново при смене модели эмбеддингов или LLM. Числа из чужого проекта не переносятся: шкала уверенности зависит и от модели, и от того, как написан промпт, и от вашей товарной специфики.

Что забрать с собой

Задачи «найти дубли в справочнике» и «сопоставить прайс поставщика» — одна и та же задача, решаемая одним конвейером: в первом случае вы ищете по каталогу самим же каталогом, во втором — чужими наименованиями. Строковое сравнение здесь не тупик из-за плохой реализации, оно отвечает не на тот вопрос. Векторный поиск отбирает кандидатов, LLM выбирает лучшего и оценивает себя, порог по этой оценке разделяет автоматику и ручную проверку. А поточная часть, которую нельзя доверить большой модели, уходит в маленькую, обученную на её разметке.

Смежные разборы, если задача шире сопоставления: как нейросеть пишет описания товаров для 1С — там про генерацию карточки, а не про поиск соответствия; ИИ-сервис анализа цен конкурентов по фото — тот же принцип кандидатов и реранкинга, но вход не текстовый, а распознанный ценник. Готовый коробочный сервис на этой архитектуре описан отдельно: сопоставление номенклатуры для 1С.

Собрать такой конвейер целиком (векторную базу, реранкер со структурированным выводом, пороги под свой каталог и обмен с 1С) мы разбираем по шагам на курсе «ИИ для 1С: разработка ИИ-агентов, RAG и MCP-серверов». Если у вас на руках справочник на десятки тысяч позиций и еженедельный прайс от поставщика, это ровно та задача, с которой стоит начинать.

Частые вопросы