RAG-система на LangChain для 1С: ответы по своей базе знаний

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

RAG-система на LangChain для 1С: рабочий код
RAG-система на LangChain для 1С: рабочий код
Видео: RAG-система на LangChain для 1С: рабочий код

Почему «дообучить модель на своих данных» — не тот ответ

Дообучение (fine-tuning) меняет поведение модели: стиль, формат, следование инструкции. Оно почти не добавляет ей знаний, зато требует датасета, видеокарты и повторного прогона при каждом изменении данных. Поменялась цена в справочнике — переобучать?

RAG решает задачу иначе. Модель остаётся как есть, а перед каждым ответом ей в промпт подкладывают нужные куски ваших документов. Обновили данные — переиндексировали за минуту. Плюс два бонуса, которых у дообучения нет: можно показать источник ответа, и можно запретить модели отвечать, если в источниках ничего не нашлось.

Две половины системы: индексация и запрос

Любая RAG-система — это два независимых конвейера.

Индексация (запускается редко, по расписанию или по кнопке):

  • взять документы — выгрузку номенклатуры, регламент, базу вопрос-ответ;
  • нарезать на чанки;
  • превратить каждый чанк в вектор (эмбеддинг);
  • сложить векторы в хранилище.

Запрос (на каждое сообщение пользователя):

  • превратить вопрос в вектор той же моделью;
  • найти ближайшие чанки;
  • собрать промпт: системная инструкция + найденный контекст + история диалога + вопрос;
  • отдать модели и вернуть ответ.

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

Индексация: документы, из которых что-то получится

Самая недооценённая часть. Качество RAG на 80% определяется тем, что вы положили в базу, а не тем, какую модель взяли.

Возьмём реальный пример — справочник образовательных программ в Markdown, где # это факультет, а ## — направление:

# Государство

## Эффективное государственное управление

Проходной балл бюджет: 300
Минимальный проходной бал по ЕГЭ:
- Русский язык 55/70
- Обществознание 55/70

Наивная нарезка по 500 символов разорвёт это в клочья, и в базу попадёт чанк «Проходной балл бюджет: 300» — сам по себе бессмысленный. Поиск его не найдёт, а если найдёт, модель не поймёт, к чему он относится.

Рабочий приём — резать по заголовкам и вклеивать метаданные в сам текст чанка:

from langchain_core.documents import Document
from langchain_text_splitters import MarkdownHeaderTextSplitter


def generate_documents() -> List[Document]:
    # Открываем файл с программами университета и считываем его содержимое
    with open("rag/programs.md", encoding="utf-8") as f:
        programs_text = f.read()

    # Определяем заголовки, по которым будем разделять текст
    headers_to_split_on = [("#", "department"), ("##", "direction")]
    splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)

    # Разделяем текст на части по заголовкам
    chunks = splitter.split_text(programs_text)

    # Каждый чанк несёт в себе факультет и направление — иначе он не ищется
    documents_list = []
    for chunk in chunks:
        doc = (
            f"Факультет: {chunk.metadata['department']}\n"
            f"Направление: {chunk.metadata['direction']}\n"
            f"Информация:{chunk.page_content}"
        )
        documents_list.append(
            Document(page_content=doc.lower(), metadata=chunk.metadata)
        )

    return documents_list

Два момента, которые тут легко пропустить.

Первое: заголовок попадает и в metadata (для фильтрации), и в page_content (для поиска). Векторный поиск смотрит только на page_content — то, чего в тексте нет, он не найдёт, как бы красиво оно ни лежало в метаданных.

Второе: .lower(). Регистр даёт разные векторы. Если вы приводите документы к нижнему регистру при индексации, то и вопрос обязаны приводить при поиске — иначе получите тихую деградацию качества, которую потом неделю ищете.

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

Векторы и хранилище: минимум для сборки

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

faiss_manager = FAISSManager(
    vector_store_name="rag",
    embedding_type="huggingface",
    embedding_kwargs={
        "model_name": "ai-sage/Giga-Embeddings-instruct",
        "model_kwargs": {"trust_remote_code": True, "device": "cpu"},
    },
)

# Один раз собрать базу из документов
# faiss_manager.generate_vector_store(documents=generate_documents())

# В обычном запуске — просто загрузить готовую
faiss_manager.load_vector_store()

ai-sage/Giga-Embeddings-instruct — русскоязычная модель эмбеддингов, работает локально, данные никуда не уходят. Для 1С-контура это часто решающий аргумент. Как устроен сам поиск, чем similarity отличается от mmr, как читать score и выбирать порог — в отдельном разборе про эмбеддинги и FAISS.

Из хранилища делается ретривер — объект, который по строке возвращает документы:

retriever = faiss_manager.vector_store.as_retriever(
    search_type="similarity",
    search_kwargs={"k": 3},
)

k=3 — сколько чанков подложить в промпт. Меньше — рискуете не дать модели нужный кусок. Больше — размываете внимание модели и платите за токены. Три-пять для начала, дальше подбирается замером.

Ретривер, который помнит разговор

Вот здесь ломается большинство самодельных RAG-ботов. Пользователь спрашивает: «Интересует эффективное государственное управление». Потом: «Сколько баллов по русскому надо набрать?».

Второй вопрос уходит в векторный поиск как есть — и находит балл по русскому во всех программах разом, потому что в самом вопросе нет ни слова про госуправление. Контекст остался в предыдущей реплике, а поиск про неё не знает.

Лечится это отдельным вызовом модели перед поиском: переписать вопрос так, чтобы он был самодостаточным.

# Определяем системный промпт для суммаризации истории чата
summary_system_prompt = (
    "Учитывая историю чата и последний вопрос пользователя, "
    "который может ссылаться на контекст в истории чата, сформулируйте отдельный вопрос, "
    "который можно понять без истории чата. "
    "НЕ отвечайте на вопрос, просто переформулируйте его, если необходимо, "
    "и в противном случае верните его как есть."
)

summary_prompt = ChatPromptTemplate.from_messages(
    [
        ("system", summary_system_prompt),
        MessagesPlaceholder("chat_history"),
        ("human", "{input}"),
    ]
)

# Ретривер, который сначала переписывает вопрос, а потом ищет
history_aware_retriever = create_history_aware_retriever(
    model, retriever, summary_prompt
)

create_history_aware_retriever делает ровно это: гоняет вопрос через модель с историей, получает «Сколько баллов по русскому нужно для направления „Эффективное государственное управление“?» и уже эту строку отдаёт в векторный поиск.

Цена — лишний вызов LLM на каждое сообщение. Обратите внимание на формулировку «НЕ отвечайте на вопрос»: без неё модель начинает отвечать вместо переписывания, и в поиск улетает готовый ответ вместо запроса.

Сборка цепочки

Дальше собирается вторая половина — промпт ответа и сама цепочка:

# Промпт, который запрещает выдумывать
qa_system_prompt = (
    "Ты AI консультант университета. Отвечай на вопросы Только Строго по источникам информации. "
    "Если в источниках нет ответа на вопрос, скажи: информация отсутствует.\n"
    "Тебе даны следующие источники информации:"
    "\n\n"
    "{context}"
)

qa_prompt = ChatPromptTemplate.from_messages(
    [
        ("system", qa_system_prompt),
        MessagesPlaceholder("chat_history"),
        ("human", "{input}"),
    ]
)

# Цепочка «сложить найденные документы в {context} и спросить модель»
question_answer_chain = create_stuff_documents_chain(model, qa_prompt)

# Цепочка «найти документы → ответить по ним»
rag_chain = create_retrieval_chain(history_aware_retriever, question_answer_chain)

{context} — служебная подстановка: create_stuff_documents_chain сам склеит туда найденные чанки. Стратегия stuff означает «запихнуть всё найденное в один промпт»; она простая и на k=3..5 работает нормально.

Запуск — обычный цикл с накоплением истории:

chat_history = []

while True:
    user_input = input("Введите сообщение: ")
    if user_input.lower() == "q":
        break

    result = rag_chain.invoke({"input": user_input, "chat_history": chat_history})
    print(f"AI: {result['answer']}")
    chat_history.append(HumanMessage(content=user_input))
    chat_history.append(AIMessage(content=result["answer"]))

Всё. Это полная RAG-система примерно на 60 строк.

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

Модель за фабрикой, а не в коде

Провайдера LLM меняют чаще, чем кажется: начали на GigaChat, упёрлись в лимиты, ушли на локальную Ollama, для сравнения качества дёрнули OpenAI. Если модель создаётся прямо в цепочке — переезд превращается в правку десятка мест.

class LlmEnum(str, Enum):
    OpenAI = "openai"
    GigaChat = "giga_chat"
    Ollama = "ollama"


class LLMFactory:
    """Фабрика для создания экземпляров языковых моделей (LLM)."""

    @staticmethod
    def create(model_type: LlmEnum, model_name: str, api_key: str = None, ...):
        if model_type == LlmEnum.OpenAI:
            return ChatOpenAI(api_key=api_key, model=model_name)
        elif model_type == LlmEnum.GigaChat:
            return GigaChat(
                credentials=api_key,
                verify_ssl_certs=verify_ssl_certs,
                model=model_name,
                temperature=temperature,
            )
        elif model_type == LlmEnum.Ollama:
            return OllamaLLM(model=model_name)
        raise ValueError(f"Модель LLM '{model_type}' не поддерживается")

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

Когда стандартного ретривера мало: поиск по номенклатуре

Учебная схема ломается на реальном справочнике 1С. Диалог с менеджером выглядит так:

— Нужны вентиляторы
— настольные
— девятьсот
— а телевизоры есть?

Ни одна из этих реплик по отдельности не является поисковым запросом. Нужен свой ретривер — LangChain позволяет унаследоваться от BaseRetriever и подставить его в ту же цепочку:

class ProductRetriever(BaseRetriever):
    llm: Any
    embedding_manager: Any
    chat_history: Any
    reranker: Any

    def _get_relevant_documents(self, query: str, *, run_manager) -> List[Document]:
        # Модель собирает из истории список законченных запросов:
        # ["вентиляторы настольные 900", "телевизоры"]
        products_list = self._summary_query(query=query)
        if len(products_list) == 0:
            products_list.append(query)

        # Ищем по последнему товару широко...
        similarity_search_result = self.embedding_manager.vector_store.similarity_search(
            query=products_list[-1], k=10
        )
        # ...и отжимаем реранкером до действительно релевантных
        return self._filter_relevant_passages(
            query=products_list[-1], documents=similarity_search_result
        )

Логика внутри _summary_query — это промпт с примерами, который учит модель отличать уточнение («девятьсот» → дописать к текущему товару) от нового запроса («а телевизоры есть?» → закрыть предыдущий, начать новый).

Второй приём здесь важнее первого: широкий поиск плюс реранкер. Векторный поиск на номенклатуре вида «Вентилятор настольный, Модель 901» / «Вентилятор оконный, модель 902» различает позиции плохо — они почти одинаковы по смыслу. Поэтому берём с запасом (k=10) и прогоняем через кросс-энкодер, который оценивает пару «запрос — документ» и отсеивает по порогу. Как это выглядит в готовом сервисе — в разборе ИИ-сервиса анализа цен, там гибридный поиск разобран в бою.

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

Учебный скрипт и сервис — разные вещи. Что приходится дописывать.

Векторная база может не загрузиться. Файла нет, путь переехал, версия FAISS сменилась. Скрипт в этом месте падает; сервис должен подняться с пустым индексом и работать дальше:

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}")

    # База не загрузилась — поднимаем пустой индекс нужной размерности
    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={},
    )

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

История диалога живёт не в переменной. В консольном примере chat_history — это список в памяти. В сервисе история у каждого пользователя своя, переживает перезапуск и хранится в БД:

async def manager(self, user_id: int, chat_type: str, question: str, callback_handler=None) -> str:
    # 1. Ищем контекст в векторной базе
    answer, links = await bot_manager["embedding_qa"].find_examples(
        message=question, callback_handler=callback_handler
    )
    # 2. Спрашиваем модель с этим контекстом и историей
    ai_response = await self.process_request(
        user_id=user_id, chat_type=chat_type,
        question=question, context=answer, links=links,
        callback_handler=callback_handler,
    )
    # 3. Сохраняем обе реплики
    await self.history.save_human_message(user_id=user_id, chat_type=chat_type, content=question)
    await self.history.save_ai_message(user_id=user_id, chat_type=chat_type, content=ai_response)
    return ai_response

Ошибка не должна валить чат. Любое исключение внутри цепочки — это внешний сервис, который отвалился. Пользователю уходит «Извините, произошла ошибка, попробуйте позже», в лог — трассировка.

Промпты — в файлах, а не в коде. read_prompt_from_file("qa_service_system.txt"). Промпт правят в десять раз чаще, чем логику, и правит его обычно не тот, кто писал сервис.

Иногда чанки не нужны вовсе. Если источник уже структурирован — например, накопленная база вопрос-ответ из поддержки, — резать нечего. В вектор кладётся вопрос, а ответ едет в метаданных:

for qa in all_qa:
    documents.append(
        Document(
            page_content=qa.question.lower(),
            metadata={"id": qa.id, "answer": qa.answer, "links": qa.links},
        )
    )

Поиск ищет по формулировке вопроса — то есть сравнивает вопрос с вопросом, а не вопрос с ответом. Точность заметно выше, и заодно появляется ссылка на документ-источник, которую можно показать пользователю.

Как понять, что RAG работает

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

Мерить это можно и нужно. Схема из трёх частей.

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

from giskard.rag import generate_testset, KnowledgeBase
from giskard.rag.question_generators import (
    simple_questions,       # простой вопрос по отрывку
    complex_questions,      # перефразированный, усложнённый
    double_questions,       # два вопроса в одном
    distracting_questions,  # с отвлекающим элементом из базы
    situational_questions,  # с контекстом пользователя
)

knowledge_base = KnowledgeBase(data=df)

testset = generate_testset(
    knowledge_base,
    question_generators=[
        simple_questions, complex_questions, double_questions,
        distracting_questions, situational_questions,
    ],
    num_questions=50,
    language="ru",
    agent_description="Служба поддержки университета, отвечает на вопросы студентов строго на русском языке.",
)

testset.save("test-dataset.jsonl")

Отвлекающие и составные вопросы — самое ценное. Простые проходят почти всегда; система разваливается ровно на «Италия прекрасна, но какая столица Франции?».

Генератор, кстати, можно направить на локальную модель — не обязательно платить за OpenAI:

api_base = "http://10.10.1.10:11434"
giskard.llm.set_llm_model("ollama/gemma3:12b-it-qat", disable_structured_output=True, api_base=api_base)
giskard.llm.set_embedding_model("ollama/bge-m3:latest", api_base=api_base)

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

Третье — оценка ответов. Сравнивать строки бессмысленно: правильный ответ можно сформулировать сотней способов. Поэтому судьёй ставят другую модель — ей дают вопрос, полученный ответ и эталонный, и просят оценку от 1 до 10:

class EvaluationQuestion(BaseModel):
    """Оценка ответа на вопрос"""
    evaluate: int = Field(description="Оценка ответа числом от 1 до 10")


response = await bot_manager["judge_llm"].send_message(
    messages=messages, schema=EvaluationQuestion, callback_handler=handler
)
score = response.evaluate / 10

handler.trace.score(name="qa_score", value=score)

Оценка пишется в тот же трейс. Дальше правка промпта или смена модели перестают быть вопросом веры: прогнали 50 вопросов до, прогнали после, сравнили среднее.

Судью берите другой моделью, не той, что отвечает — иначе она щедро хвалит собственные ответы.

Как это подключается к 1С

Схема стандартная: RAG живёт отдельным сервисом на FastAPI, 1С обращается к нему обычным HTTP-запросом.

Сервис поднимает векторную базу и модель один раз при старте (в lifespan), а не на каждый запрос — загрузка модели эмбеддингов занимает секунды. Наружу торчит один эндпоинт: принял вопрос и идентификатор пользователя, вернул ответ. Со стороны 1С это HTTPСоединение, JSON в теле и разбор ответа — та же механика, что в любой интеграции с внешним сервисом.

Подробнее про обвязку — три архитектуры внедрения ИИ в 1С и разбор ИИ-агента для 1С на LangGraph, где сервис уже дёргается кнопкой из УТ.

Если нужен не разрозненный набор рецептов, а последовательная сборка такого сервиса — от Poetry и структуры проекта до подключения к типовой конфигурации, — это курс «ChatGPT и нейросети в 1С»: там весь путь разбирается на живых проектах.

Что дальше

RAG собирается быстро, а вот доводится до приемлемого качества долго — и почти всегда правками в индексации, а не в модели. Порядок, в котором стоит крутить ручки:

  • Нарезка. Разберитесь, что реально попало в базу — про сплиттеры и метаданные в чанках.
  • Поиск. Модель эмбеддингов, k, пороги отсечки — про эмбеддинги, FAISS и score.
  • Реранкер. Когда документы похожи друг на друга, без него не обойтись.
  • Промпт. Жёсткий запрет выдумывать — самая дешёвая правка с самым большим эффектом.
  • Замер. Всё предыдущее бессмысленно, пока вы не измеряете результат.

И только потом — более дорогая модель.

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