Поиск дублей в 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С — плоский список позиций (код, наименование, артикул, цена).
- Векторизация каталога — один раз при загрузке, база сохраняется на диск.
- Поиск top-N кандидатов по строке поставщика — быстро, дёшево, без LLM.
- LLM-реранкер — модель смотрит на запрос и на N кандидатов, выбирает одного и возвращает
confidence. - Порог по 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-серверов». Если у вас на руках справочник на десятки тысяч позиций и еженедельный прайс от поставщика, это ровно та задача, с которой стоит начинать.