Семантический поиск в 1С: эмбеддинги и FAISS на практике

Семантический поиск в 1С начинается с обычного провала: менеджер ищет в справочнике номенклатуры «настольный вентилятор 902», а поиск по подстроке возвращает пустоту — позиция в базе называется «Вентилятор настольный, Модель 902». Эмбеддинги закрывают ровно эту дыру: они превращают текст в вектор смысла, и близкие по смыслу строки оказываются рядом даже без единой общей подстроки. Ниже — разбор без магии: как устроен векторный поиск, как собрать базу на FAISS, как читать score, какую модель эмбеддингов брать для данных 1С и что из этого ломается в проде.

Код в статье — из моих учебных и боевых проектов на LangChain: локальная модель ai-sage/Giga-Embeddings-instruct плюс FAISS в учебной части и связка «провайдер эмбеддингов + FAISS + трассировка» в продовой.

Семантический поиск в 1С: эмбеддинги и FAISS
Семантический поиск в 1С: эмбеддинги и FAISS
Видео: Семантический поиск в 1С: эмбеддинги и FAISS

Почему поиск по подстроке проваливается на справочнике номенклатуры

Возьмём десяток реальных наименований — такие лежат в любой торговой базе 1С:

Вентилятор JIPONIC (Тайв.), напольный
Вентилятор настольный, Модель 901
Вентилятор настольный, Модель 902
Вентилятор оконный, модель 900
Вентилятор оконный, модель 902
Пылесос Омега 1250вт
Пылесос Энергия-SANYO
Телевизор JVC
Рубин - 340
Телевизор SHARP

Теперь три запроса, которые реально приходят от менеджера или из чата с клиентом:

  • «нужен вентилятор на стол» — подстрока «на стол» не встречается нигде, «настольный» не найдётся;
  • «девятьсот вторая модель» — цифры записаны словами;
  • «что-нибудь для уборки» — слова «пылесос» в запросе нет вообще.

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

Что такое эмбеддинг простыми словами

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

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

1. Размерность фиксирована моделью. Не длиной текста. И слово, и абзац на 500 знаков дадут вектор одной и той же длины. Узнать её проще всего практикой — в продовом коде я так и делаю:

# Размерность берём у самой модели, а не из документации
index = faiss.IndexFlatL2(len(self.embeddings.embed_query("hello world")))

2. Близость — это метрика на векторах. Обычно косинус угла между ними: 1 — направления совпадают, 0 — не связаны. FAISS по умолчанию считает не косинус, а евклидово расстояние L2 (IndexFlatL2) — меньше значит ближе. Практически важный нюанс: если векторы нормализованы (приведены к единичной длине), порядок результатов по L2 и по косинусу совпадает. Отсюда параметр normalize_embeddings, к которому вернёмся в разделе про выбор модели.

3. Вектор считается один раз при индексации. Поиск потом — это векторизация одного короткого запроса плюс сравнение с уже готовым индексом. Дорогая операция — построение базы, а не сам поиск.

Демо, после которого всё становится понятно

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

document_1 = Document(page_content="собака", metadata={"type": "животные"})
document_2 = Document(page_content="кошка",  metadata={"type": "животные"})
document_3 = Document(page_content="дом",    metadata={"type": "строения"})
document_4 = Document(page_content="коттедж", metadata={"type": "строения"})

faiss_manager.add_documents([document_1, document_2, document_3, document_4])

А теперь запросы, в которых нет ни одного слова из базы:

faiss_manager.similarity_search_with_score("расскажи про породу лабрадор", k=3)
faiss_manager.similarity_search_with_score("хотел бы купить что-то за городом", k=3)
faiss_manager.similarity_search_with_score("чем их кормить?", k=3)
faiss_manager.similarity_search_with_score("есть у вас в продаже кроссовки?", k=3)

«Породу лабрадор» уводит к «собаке», хотя слова «собака» в запросе нет. «Хотел бы купить что-то за городом» вытаскивает «коттедж» — модель знает, что коттедж это загородное строение. «Чем их кормить» тянется к животным. А вот «кроссовки» — важный случай: релевантного ответа в базе нет, но similarity_search всё равно вернёт три документа, потому что вы попросили k=3. Просто с плохими значениями score. Это не баг, это ключевое свойство векторного поиска, к которому вернёмся ниже.

Перенесите этот же приём на номенклатуру — и «нужен вентилятор на стол» найдёт «Вентилятор настольный, Модель 902», а «что-нибудь для уборки» вытащит пылесосы. Готовый сервис, построенный ровно на этом принципе, я разбирал отдельно — сопоставление номенклатуры с помощью ИИ.

FAISS: минимальный работающий менеджер базы

FAISS — библиотека Facebook AI для поиска ближайших векторов. Она не сервер и не СУБД: это индекс в памяти процесса, который умеет сохраняться на диск. Для базы знаний в пару сотен тысяч документов этого хватает с запасом, а разворачивать нечего — pip install faiss-cpu.

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

class FAISSManager:
    def __init__(self, vector_store_name, embedding_type, embedding_kwargs):
        self.embeddings = EmbeddingFactory.create_embedding(embedding_type, **embedding_kwargs)
        self.vector_store_directory = "vector_store"
        self.vector_store_name = vector_store_name
        self.vector_store = None

    def generate_vector_store(self, documents: list[Document]):
        # Здесь и происходит векторизация: каждый документ прогоняется через модель
        self.vector_store = FAISS.from_documents(documents=documents, embedding=self.embeddings)
        self.vector_store.save_local(
            folder_path=self.vector_store_directory, index_name=self.vector_store_name
        )

    def load_vector_store(self):
        try:
            self.vector_store = FAISS.load_local(
                folder_path=self.vector_store_directory,
                index_name=self.vector_store_name,
                embeddings=self.embeddings,
                allow_dangerous_deserialization=True,
            )
        except Exception as e:
            self.vector_store = None
            print("Ошибка загрузки базы FAISS", e.__str__())

    def add_documents(self, documents: list[Document]):
        # Базы ещё нет — создаём с нуля, есть — дописываем
        if self.vector_store is None:
            self.generate_vector_store(documents)
        else:
            self.vector_store.add_documents(documents)

save_local кладёт на диск два файла: <name>.faiss — сам индекс векторов, и <name>.pkl — docstore с исходными текстами и метаданными.

Почему allow_dangerous_deserialization называется так страшно

Флаг обязателен при загрузке, и не просто так. Файл .pkl — это Python-pickle, а распаковка pickle умеет выполнять произвольный код: формат хранит не только данные, но и инструкции по восстановлению объектов. Загрузить чужой .pkl = запустить чужой код в своём процессе. LangChain не может проверить, откуда файл, поэтому перекладывает ответственность на вас через явный флаг.

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

Метаданные: они важнее, чем кажется

В индекс попадает page_content — по нему считается вектор и по нему идёт поиск. А metadata — обычный словарь, который едет рядом и в векторизации не участвует. Из этого следует главный приём: в page_content кладите то, по чему ищут, а в metadata — то, что нужно отдать.

Показательный пример из продового сервиса — база «вопрос-ответ» генерируется прямо из таблицы БД:

for qa in all_qa:
    documents.append(
        Document(
            page_content=qa.question.lower(),   # ищем ПО ВОПРОСУ
            metadata={
                "id": qa.id,
                "answer": qa.answer,            # ответ просто едет пассажиром
                "links": qa.links,
            },
        )
    )
    ids.append(qa.id)

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

Ответ не векторизуется вообще. Ищем по формулировке вопроса, потому что запрос пользователя похож именно на вопрос, а не на ответ. Для 1С это переводится один в один: индексируем наименование номенклатуры, а в метаданные кладём код, артикул, GUID, единицу измерения — всё, что потом понадобится, чтобы найти объект в базе.

Обратите внимание на ids=ids: передав идентификаторы из своей таблицы, вы получаете возможность точечно удалять и перезаписывать документы, а не пересобирать всю базу при правке одной позиции.

Фильтр по метаданным

Метаданные умеют сужать поиск — это дешёвый способ не смешивать разнородные данные в одном индексе:

results = self.vector_store.similarity_search(
    question.lower(), k=k, filter={"type": "животные"}
)

С фильтром {"type": "животные"} запрос «расскажи про породу лабрадор» физически не сможет вернуть «коттедж». В боевом сценарии на месте type обычно организация, склад, вид номенклатуры или признак «архивная позиция».

Зачем везде .lower()

Заметили question.lower() в поиске и qa.question.lower() при индексации? Это не суеверие. Регистр меняет токенизацию, а значит и вектор: «Вентилятор» и «вентилятор» дадут пусть близкие, но разные точки. Разница мизерная, однако когда вы отсекаете результаты по порогу, мизерная разница как раз и решает. Дисциплина здесь простая: одинаковая нормализация текста при индексации и при поиске. Если при сборке базы вы понизили регистр — обязаны сделать то же самое с запросом.

Score и пороги: почему «ничего не найдено» — это тоже результат

Главная ловушка векторного поиска: он всегда что-то возвращает. Попросили k=3 — получите три документа, даже если база про животных, а спросили про кроссовки. Отсекать мусор обязаны вы.

results = self.vector_store.similarity_search_with_score(question.lower(), k=k, filter=meta_filter)
for res, score in results:
    print(f"* [SIM={score:.3f}] {res.page_content} [{res.metadata}]")

Как читать это число:

  • у FAISS с IndexFlatL2 это расстояние, а не схожесть: меньше — ближе. Ноль означал бы точное совпадение векторов;
  • шкала не абсолютная. Порог 0.35 в одной базе и на одной модели ничего не говорит о порогах в другой. При смене модели эмбеддингов пороги надо перекалибровать заново;
  • калибруется порог только эмпирически: берём 30–50 реальных запросов, размечаем руками, что считается попаданием, и смотрим, где проходит граница.

В коде это выглядит как обычное отсечение перед возвратом ответа. И это принципиальный момент для 1С-сценария: сервис, который на вопрос «а есть у вас перфоратор?» уверенно выдаёт телевизор SHARP, хуже сервиса, который честно отвечает «не нашёл». Пустой ответ — валидный ответ, его надо уметь возвращать.

Retriever, k и режим mmr

В связках LangChain база обычно используется не напрямую, а через ретривер — единый интерфейс поиска, который потом подставляется в цепочку:

def search_retriever(self, question, meta_filter=None, k=1, search_type="similarity"):
    retriever = self.vector_store.as_retriever(
        search_type=search_type, search_kwargs={"k": k}
    )
    results = retriever.invoke(question.lower(), filter=meta_filter)
    for res in results:
        print(f"* {res.page_content} [{res.metadata}]")

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

k — сколько документов вернуть. Соблазн поставить k=1 и не думать. На практике это плохо работает: единственный кандидат лишает вас возможности отфильтровать или переупорядочить выдачу. Рабочая схема — брать с запасом (k=10), а потом сокращать порогом. Сравните вызовы:

faiss_manager.search_retriever(question="расскажи про породу лабрадор")
faiss_manager.search_retriever(question="расскажи про породу лабрадор", k=10)
faiss_manager.search_retriever(question="расскажи про породу лабрадор", k=10,
                               meta_filter={"type": "животные"})
faiss_manager.search_retriever(question="расскажи про породу лабрадор", k=10,
                               meta_filter={"type": "животные"}, search_type="mmr")

search_type — чистая близость или разнообразие. При "similarity" вы получите k самых близких документов — и если в базе лежат «Вентилятор настольный, Модель 901» и «Модель 902», топ забьётся почти одинаковыми строками. Режим "mmr" (maximal marginal relevance) на каждом шаге выбирает документ, который одновременно близок к запросу и не похож на уже выбранные. Когда это нужно: подбор контекста для LLM, где важно покрыть тему с разных сторон. Когда вредно: поиск конкретного товара, где вам нужны именно все близкие варианты подряд.

Какую модель эмбеддингов выбрать

Это решение стоит принять до того, как вы соберёте базу на миллион документов: векторы разных моделей несовместимы. Смена модели = полная пересборка индекса и перекалибровка порогов.

В боевом сервисе я вынес выбор в отдельного провайдера — три варианта под три ситуации:

class EmbeddingsProvider:
    @staticmethod
    def get_embeddings():
        if settings.embedding.model_type == EmbeddingEnum.OpenAI:
            return OpenAIEmbeddings(
                api_key=settings.llm.gpt_auth,
                openai_proxy=settings.llm.proxy_url,
                model="text-embedding-3-large",
            )
        elif settings.embedding.model_type == EmbeddingEnum.GigaChatEmbeddingsOnline:
            return GigaChatEmbeddings(
                credentials=settings.llm.giga_auth,
                verify_ssl_certs=False,
            )
        elif settings.embedding.model_type == EmbeddingEnum.BGE_Offline:
            model_name = "BAAI/bge-m3"
            model_kwargs = {"trust_remote_code": True}
            encode_kwargs = {"normalize_embeddings": True}
            return HuggingFaceEmbeddings(
                model_name=model_name,
                model_kwargs=model_kwargs,
                encode_kwargs=encode_kwargs,
            )
        else:
            raise ValueError(f"Модель '{settings.embedding.model_type}' не поддерживается.")

Разбор по вариантам.

Облачные: OpenAI text-embedding-3-large. Лучшее качество на смешанных текстах, ноль забот с железом. Два минуса, оба серьёзные для 1С: нужен прокси (виден в коде параметром openai_proxy) и каждый документ вашей базы уезжает наружу. Для справочника номенклатуры это, может, и терпимо. Для базы договоров или переписки с клиентами — нет.

Облачные российские: GigaChatEmbeddings. Работает без прокси, данные остаются в российском контуре, интеграция в LangChain готовая. Тот же вопрос приватности, но в юрисдикции, которую проще согласовать с безопасностью.

Локальные: BAAI/bge-m3 и ai-sage/Giga-Embeddings-instruct. Модель качается с HuggingFace и крутится на вашей машине — данные не покидают контур вообще. Именно этот вариант я беру в учебных примерах:

faiss_manager = FAISSManager(
    vector_store_name="products",
    embedding_type="huggingface",
    embedding_kwargs={
        "model_name": "ai-sage/Giga-Embeddings-instruct",
        "model_kwargs": {
            "trust_remote_code": True,
            "device": "cpu",   # без видеокарты работает, просто медленнее
        },
    },
)

Два практических предупреждения. trust_remote_code: True означает, что вместе с весами исполнится код из репозитория модели — берите модели только от вменяемых авторов. А device: "cpu" честно работает, но на первичной векторизации крупного справочника вы это почувствуете: индексация десятков тысяч наименований на процессоре — это часы, а не минуты.

Про normalize_embeddings: True. В блоке bge-m3 он выставлен намеренно. Нормализация приводит все векторы к единичной длине, после чего L2-расстояние FAISS упорядочивает результаты так же, как косинус. Побочный эффект приятный: значения score становятся сопоставимыми между запросами, и порог наконец-то ведёт себя предсказуемо. Если ваша модель отдаёт ненормализованные векторы, а вы ставите жёсткую отсечку — вы каждый раз играете в лотерею.

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

Что ломается в проде

Учебный пример живёт, пока файл на месте и модель отвечает. Продовый — нет. Четыре вещи, на которых я обжигался.

1. База не загрузилась — сервис не должен падать

Первый запуск, потерянный volume, недокачанный при деплое файл индекса. Если load_local кинет исключение и вы его не поймаете, весь сервис ляжет при старте. Рабочая схема — двухступенчатая инициализация с откатом на пустой индекс:

def init_db(self):
    try:
        self.vector_store = FAISS.load_local(
            folder_path=self.emb_db_directory,
            index_name=self.emb_db_name,
            embeddings=self.embeddings,
            allow_dangerous_deserialization=True,
        )
        self.retriever = self.vector_store.as_retriever(search_kwargs={"k": 100})
        return True
    except Exception as e:
        error_message(f"Ошибка загрузки базы FAISS: {e}")

    try:
        # Базы нет — поднимаем ПУСТОЙ индекс правильной размерности,
        # чтобы сервис стартовал и принимал запросы на генерацию
        index = faiss.IndexFlatL2(len(self.embeddings.embed_query("hello world")))

        self.vector_store = FAISS(
            embedding_function=self.embeddings,
            index=index,
            docstore=InMemoryDocstore(),
            index_to_docstore_id={},
        )
        self.retriever = self.vector_store.as_retriever(search_kwargs={"k": 100})
    except Exception as e:
        error_message(f"Ошибка инициализации базы FAISS: {e}")

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

2. Поиск надо видеть, а не угадывать

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

if callback_handler:
    span = callback_handler.trace.span(
        name="similarity_search",
        metadata={"database": self.emb_db_name},
        input={"query": message, "k": k},
    )
else:
    span = None

try:
    docs = self.vector_store.similarity_search(query=message.lower(), k=k)
except Exception as e:
    error_message(f"Ошибка поиска по схожести: {e}")
    if span:
        span.end(output={"error": str(e)})
    raise

if span:
    span.end(output={"results": [
        {"page_content": doc.page_content, "metadata": doc.metadata} for doc in docs
    ]})

Ключевое: спан закрывается и в успехе, и в ошибке, а исключение пробрасывается дальше — трассировка не должна глотать ошибки. В интерфейсе Langfuse потом видно каждый запрос вместе с тем, что по нему нашлось.

3. Справочник меняется — база устаревает молча

Векторная база — это снимок. Завели вчера сто новых позиций номенклатуры — их в индексе нет, и никто вам об этом не скажет: поиск просто вернёт что-то похуже. Варианты, в порядке роста сложности: полная пересборка по расписанию (для справочника на десятки тысяч позиций ночью — нормально), инкрементальный add_documents по изменённым объектам, точечная перезапись через ids. И обязательный элемент — версия базы рядом с самой базой, чтобы было видно, на каком состоянии справочника она собрана.

4. Векторизация стоит денег и времени

Цифра из практики: справочник на 10 тысяч товаров с описаниями — это порядка миллиона токенов на векторизацию у GigaChat. Один раз это терпимо. Но если вы решили пересобирать базу ежедневно или дважды поменяли модель эмбеддингов — счёт начинает удивлять. У локальной модели вместо денег платите временем CPU/GPU, и на слабом сервере первая индексация большого справочника легко занимает ночь. Считать это надо до старта, а не после первого счёта.

Отдельная статья расходов — размер того, что вы векторизуете. Наименование номенклатуры — это одна строка, а вот база знаний из регламентов и инструкций режется на фрагменты, и от того, как вы её разрежете, зависит и стоимость, и качество поиска. Про это отдельный разбор — разбиение текста на чанки в LangChain.

Куда двигаться дальше

Векторный поиск — фундамент, но не законченное решение. Три направления, в которых он развивается.

Реранкинг. Ближайший шаг, если топ выдачи «почти правильный», но нужный документ болтается на третьем месте. Идея: взять k=10 кандидатов от FAISS и переупорядочить их отдельной моделью-реранкером (BAAI/bge-reranker-v2-m3 и подобные), которая смотрит на пару «запрос + документ» целиком, а не на два независимых вектора. Точнее и заметно дороже, поэтому и применяется поверх быстрого векторного отбора. В боевом виде — с порогами отсечки и разбором, что это даёт на реальных данных — реранкинг разобран в статье про ИИ-сервис анализа цен конкурентов по фото.

Гибридный поиск. Векторы плохо работают там, где нужно точное совпадение: артикулы, серийные номера, коды. Рабочая практика — сложить векторный поиск с обычным полнотекстовым и объединить выдачи.

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

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

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