Семантический поиск в 1С начинается с обычного провала: менеджер ищет в справочнике номенклатуры «настольный вентилятор 902», а поиск по подстроке возвращает пустоту — позиция в базе называется «Вентилятор настольный, Модель 902». Эмбеддинги закрывают ровно эту дыру: они превращают текст в вектор смысла, и близкие по смыслу строки оказываются рядом даже без единой общей подстроки. Ниже — разбор без магии: как устроен векторный поиск, как собрать базу на FAISS, как читать score, какую модель эмбеддингов брать для данных 1С и что из этого ломается в проде.
Код в статье — из моих учебных и боевых проектов на LangChain: локальная модель ai-sage/Giga-Embeddings-instruct плюс FAISS в учебной части и связка «провайдер эмбеддингов + 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С-программиста: там весь путь от справочника номенклатуры до работающего поискового сервиса, с разбором ошибок на живых данных.